diff --git a/README.md b/README.md index f11451e..65e3e66 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,7 @@ Channels takes webhooks from each provider, checks them, saves them, and hands t ## Try it -[`examples/alchemy-cloudflare`](./examples/alchemy-cloudflare/) is a complete bot for Slack, GitHub, and Linear on a Cloudflare Worker with Durable Objects, deployed with [Alchemy](https://alchemy.run). It includes a fake remote agent, so you can watch a handoff end to end: mention the bot with `handoff 30 plan react` and see the status line, plan, reactions, summary, and final message arrive. - -```sh -cd examples/alchemy-cloudflare -cp .env.example .env # fill in the providers you want -bun alchemy deploy -``` - -Its [README](./examples/alchemy-cloudflare/README.md) walks through setting up each app and lists checks to run by hand. +[`examples/alchemy-cloudflare`](./examples/alchemy-cloudflare/) is a coding agent for GitHub, built on Channels and running entirely on Cloudflare. Mention it on an issue or pull request and it works on the repository in its own Linux workspace, then answers in the thread. Its [README](./examples/alchemy-cloudflare/README.md) shows how the pieces fit, and [SETUP.md](./examples/alchemy-cloudflare/SETUP.md) shows how to deploy it. ## How it works @@ -291,10 +283,7 @@ Every callback's second argument describes the delivery it is running: Pick a store, then join everything with `Channels.make`. You get webhook routes (`bot.routes`) and, if you hand off, the delivery API (`bot.deliveryApi`). Mount both next to your own routes. ```ts -import { createServer } from 'node:http' import * as NodeCrypto from '@effect/platform-node/NodeCrypto' -import * as NodeHttpServer from '@effect/platform-node/NodeHttpServer' -import * as NodeRuntime from '@effect/platform-node/NodeRuntime' import { PgClient } from '@effect/sql-pg' import { Channels } from '@humanlayer/channels-delivery' import { ChannelsSql } from '@humanlayer/channels-sql' @@ -320,33 +309,20 @@ const bot = Channels.make({ storage: ChannelsSql.make({ claimLimit: 50, runMigrations: true, polling: { intervalMs: 1_000 } }), }) +// Your routes and the bot's, in one router. const MyRoutes = HttpRouter.add('GET', '/health', HttpServerResponse.text('ok')) - -const Server = HttpRouter.serve(Layer.mergeAll(MyRoutes, bot.routes, bot.deliveryApi), { - // Delivery IDs are longer than the router's default path parameter limit. - routerConfig: Channels.routerConfig, -}).pipe( - Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })), +const App = Layer.mergeAll(MyRoutes, bot.routes, bot.deliveryApi).pipe( Layer.provide(NodeCrypto.layer), Layer.provide(PgClient.layerConfig({ url: Config.Redacted('DATABASE_URL') })), ) -Layer.launch(Server).pipe(NodeRuntime.runMain) -``` - -The routes bring mailbox processing with them. For other setups: - -```ts -// A worker that processes mailboxes but serves no HTTP. -Layer.launch(bot.layer.pipe(Layer.provide(services))) +// Delivery IDs are longer than the router's default path parameter limit. +const { handler } = HttpRouter.toWebHandler(App, { routerConfig: Channels.routerConfig }) -// Without Effect: a plain Request to Response function, for Bun.serve, Hono, Next.js, and so on. -const started = await bot.start(services, bot.deliveryApi) -Bun.serve({ port: 3000, fetch: started.handle }) -// later: await started.stop() +Bun.serve({ port: 3000, fetch: (request) => handler(request) }) ``` -Nothing runs until one of these is built, and a program that uses more than one still gets one polling loop. +The routes bring mailbox processing with them: building the handler starts it. If your server already uses `HttpRouter`, add `bot.routes` and `bot.deliveryApi` to it the same way. Without Effect, `bot.start(services, bot.deliveryApi)` gives you a plain `Request` to `Response` function and a `stop()`. | Store | Package | Call | | ---------- | --------------------------------------------------------------- | ------------------------- | diff --git a/examples/alchemy-cloudflare/README.md b/examples/alchemy-cloudflare/README.md index 1b9cc09..b91cb74 100644 --- a/examples/alchemy-cloudflare/README.md +++ b/examples/alchemy-cloudflare/README.md @@ -1,334 +1,188 @@ -# Alchemy Cloudflare GitHub mailbox example - -This deployment receives GitHub webhooks in a Cloudflare Worker, stores each event in a mailbox Durable Object, and processes mailboxes from Durable Object alarms. `src/GithubBot.ts` declares the GitHub callbacks, `src/Worker.ts` builds ingress and the Workers AI binding, and `src/DeliveryMailboxDO.ts` builds persistent processing. New issues and PRs are labeled with Clef. A mention from a maintainer starts a coding agent on the issue or pull request; see [The GitHub agent](#the-github-agent). The older Slack, Linear, and fake-agent walkthroughs below are not part of the current deployment. - -## Linear Application setup - -Create a Linear Application for the workspace with the scopes and webhook categories listed in [`packages/linear/README.md`](../../packages/linear/README.md), and set its webhook URL to: - -```text -https:///integrations/linear/webhook -``` - -Use the app actor with either an application developer token: - -```dotenv -LINEAR_WEBHOOK_SECRET=... -LINEAR_DEVELOPER_TOKEN=... -LINEAR_ORGANIZATION_ID=... -LINEAR_APP_USER_ID=... -``` - -or OAuth client credentials: - -```dotenv -LINEAR_WEBHOOK_SECRET=... -LINEAR_CLIENT_ID=... -LINEAR_CLIENT_SECRET=... -LINEAR_ORGANIZATION_ID=... -LINEAR_APP_USER_ID=... -``` - -When both authentication forms are configured, `LINEAR_DEVELOPER_TOKEN` takes precedence. The configured organization and app-user IDs are explicit identity expectations, and both authentication paths verify them with a lazy `viewer` query before the first provider operation. Client credentials acquire and renew short-lived tokens automatically; a developer token remains caller-managed. No credential is placed in webhook admissions or Durable Object mailbox state. - -`AgentSessionEvent.created` and `AgentSessionEvent.prompted` are the authoritative agent entry points. Created sessions receive an automatic ephemeral thought before application code runs; both example callbacks then emit a terminal response activity so the Linear card completes. The corresponding Inbox Notification mention and assignment events are still authenticated and decoded, but are acknowledged as supplemental signals instead of starting duplicate work. - -The automatic thought, and every activity a handed-off session turn posts through the delivery API, carries its own UUID. Linear refuses a second activity with an ID it has seen, and the package counts that refusal as done, so a retry never posts one twice. Activities a callback posts itself (`session.thought`, `session.respond`) let Linear choose the ID and stay at-least-once: a retry after one succeeded can post it again. - -For a live check, use a unique `channels-live-p2-` marker: mention the app on one issue and delegate a second issue to it. Each action should create one session-scoped mailbox, show an ephemeral thought within ten seconds, invoke `onAgentSessionCreated` once, and finish with the example's terminal response. Send a follow-up message in the session and confirm `onAgentSessionPrompted` uses the same mailbox and produces one response. Corresponding Inbox Notification deliveries must not invoke the legacy mention or assignment callbacks. Logs include only organization, session, issue, delivery, and prompt activity IDsβ€”never prompts, guidance, credentials, signatures, or payload bodies. - -## GitHub App setup - -GitHub Apps have an equivalent to Slack manifests, called the [GitHub App Manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest). Unlike Slack's pasteable manifest, GitHub's manifest is submitted by a web page and completed through a callback that exchanges a temporary code for the App ID, private key, and webhook secret. This example does not host that registration callback. `github-app-manifest.example.json` records the exact manifest settings, but the quickest way to test one repository is to create the app manually. - -### 1. Start or deploy the Worker - -```bash -cd examples/alchemy-cloudflare -cp .env.example .env -bun alchemy dev -``` - -You need a public HTTPS Worker URL before GitHub can deliver webhooks. The GitHub webhook URL is: - -```text -https:///integrations/github/webhook +# A coding agent for GitHub, on Cloudflare + +Mention the app on an issue or pull request. It reacts πŸ‘€, clones the repository into its own Linux workspace, does the work, and answers in the thread. It can push a branch and open a pull request. It also labels new issues and pull requests. + +It runs entirely on Cloudflare: Workers, Durable Objects, and a container. [Channels](../../README.md) handles GitHub's webhooks, [Fold](https://github.com/humanlayer/fold) runs the agent, and `@cloudflare/computer` gives it a workspace. It is deployed with [Alchemy](https://alchemy.run). + +To run it yourself, see [SETUP.md](./SETUP.md). + +## The pieces + +```mermaid +flowchart LR + GH([GitHub]) + OAI([OpenAI]) + + subgraph Ingress["Ingress Worker"] + W[Webhook routes] + API[Delivery API] + end + + subgraph Mailbox["DeliveryMailbox DO"] + Q[(Saved events)] + CB[Callbacks] + end + + subgraph Session["AgentSession DO"] + F[Fold agent] + end + + subgraph Computer["Computer DO"] + FS[(Files + git)] + SH[Shell] + C[Container] + end + + GH -- 1 webhook --> W + W --> Q --> CB + CB -- 2 hand off --> F + F -- 3 model --> OAI + F -- 4 tools --> FS + FS --- SH + FS --- C + F -- 5 report --> API + API --> Q + Q -- 6 post --> GH ``` -### 2. Register the GitHub App - -Open **GitHub Settings β†’ Developer settings β†’ GitHub Apps β†’ New GitHub App**, then configure: - -- **GitHub App name:** any globally unique name. -- **Homepage URL:** your Worker URL or project homepage. -- **Webhook:** active. -- **Webhook URL:** the URL above. -- **Webhook secret:** generate a strong value, for example with `openssl rand -hex 32`. -- **Where can this GitHub App be installed?:** choose **Only on this account** for local testing. - -OAuth callback URLs, user authorization, device flow, and post-installation setup URLs are not needed. - -### 3. Repository permissions - -Configure these permissions under **Repository permissions**: - -| Permission | Access | Why | -| ------------- | -------------- | -------------------------------------------------------------------------------------------------- | -| Metadata | Read-only | Repository identity; GitHub grants this mandatory permission to installed apps. | -| Issues | Read and write | Read and change issues, issue comments, and labels shared by issues and PRs. | -| Pull requests | Read and write | Read, open, and change PRs, files, commits, conversation and review comments, reviews, and labels. | -| Checks | Read-only | Receive completed check-run events; list check runs; read check output and annotations. | -| Contents | Read and write | Clone, pull, and push branches, and merge pull requests. | -| Actions | Read-only | Resolve GitHub Actions-backed checks to jobs, read job details, and download job logs. | - -No Administration, organization, or account permissions are required. - -If you change permissions after installing the app, approve the new permission request for the installation or reinstall the app before testing again. - -The example manifest grants every permission needed by the package's resource methods. If you only consume callbacks and call read methods, Issues and Pull requests can be read-only. Omit Contents when the application cannot merge and Actions when it cannot inspect GitHub Actions jobs or logs. The included sample posts comments and reactions, so it needs Issues and Pull requests write access; it does not itself merge PRs or download logs. - -### 4. Subscribe to events - -Enable exactly these events under **Subscribe to events**: - -| GitHub setting | Webhook name | Used for | -| --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------ | -| Issues | `issues` | Open, edit, close, reopen, assignment, and label activity. | -| Issue comment | `issue_comment` | Issue and PR conversation comments, including mentions. | -| Pull request | `pull_request` | PR lifecycle, assignment, labels, synchronization, draft state, and review requests. | -| Pull request review | `pull_request_review` | Submitted, edited, and dismissed reviews. | -| Pull request review comment | `pull_request_review_comment` | Inline review-comment creation, edits, deletion, and mentions. | -| Pull request review thread | `pull_request_review_thread` | Review-thread resolution and reopening. | -| Check run | `check_run` | Completed checks, fanned out to every associated PR. | - -The provider ignores unsupported actions, and it ignores check runs that are not associated with a pull request. `check_suite`, `push`, `workflow_run`, and other events are not needed. - -### 5. Generate a private key and install the app - -After creating the app: - -1. On the app settings page, note the numeric **App ID**. -2. Under **Private keys**, generate and download a private key. -3. Open **Install App**, install it on your test account, and grant it access to the repository you want to test. -4. Find the app slug in its settings URL or public URL. The bot login is `[bot]`. -5. Resolve the bot's numeric user ID: - -```bash -curl --fail --silent "https://api.github.com/users/%5Bbot%5D" | jq .id +Every issue or pull request gets three Durable Objects, all named after it. The numbers follow one mention: (1) the webhook is saved, (2) the mention is handed to the agent, (3–4) the agent thinks and works in its Computer, (5) it reports back, and (6) Channels posts on GitHub. + +| Object | What it holds | Code | +| ------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **DeliveryMailbox** | GitHub events for this discussion, in order, until each is handled | [`DeliveryMailboxDO.ts`](./src/DeliveryMailboxDO.ts), [`GithubBot.ts`](./src/GithubBot.ts) | +| **AgentSession** | The agent's conversation, the delivery it is working on, and what it has seen | [`AgentSessionDO.ts`](./src/AgentSessionDO.ts), [`DeliveryTurn.ts`](./src/DeliveryTurn.ts) | +| **Computer** | The cloned repository, its files and git data, and a Linux container | [`computer/Computer.ts`](./src/computer/Computer.ts), [`Workspace.ts`](./src/Workspace.ts) | + +## What happens on a mention + +```mermaid +sequenceDiagram + autonumber + participant GH as GitHub + participant W as Ingress Worker + participant MB as DeliveryMailbox + participant AS as AgentSession + participant CP as Computer + participant AI as OpenAI + + GH->>W: webhook: "@app why does this fail?" + W->>MB: save the event + Note over MB: waits 3s for more events (debounce) + MB->>GH: πŸ‘€ on the comment + MB->>AS: send(prompt, delivery ID + token) + AS-->>MB: accepted + Note over MB: delivery handed off,
later events wait + AS->>CP: clone or pull the branch + AS->>GH: read what's new in the discussion + loop until the agent answers + AS->>AI: conversation + tools + AI-->>AS: tool calls + AS->>CP: run them: bash, files, git + end + AS->>W: complete(answer) via delivery API + W->>MB: finish the delivery + MB->>GH: post the answer, remove πŸ‘€ + Note over MB: next waiting events run ``` -### 6. Configure the example +## Channels: from webhook to handoff -Put these values in `examples/alchemy-cloudflare/.env`: +Channels turns GitHub's webhooks into an ordered, durable stream per discussion. -```dotenv -GITHUB_WEBHOOK_SECRET=the-secret-entered-in-the-github-app-settings -GITHUB_APP_ID=123456 -GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- -paste-the-complete-downloaded-key-here ------END RSA PRIVATE KEY-----" -GITHUB_BOT_MENTION_NAME=your-app-slug -GITHUB_BOT_USER_ID=123456789 -OPENAI_API_KEY=sk-... +```mermaid +stateDiagram-v2 + direction LR + [*] --> Saved: webhook + Saved --> Running: debounce ends, alarm fires + Running --> Done: callback returns + Running --> HandedOff: callback hands off + HandedOff --> Done: complete / fail via delivery API + Running --> Saved: callback fails, retry + Done --> [*] ``` -The agent runs on OpenAI's `gpt-6.1-sol` at medium reasoning, using `OPENAI_API_KEY`. Workers AI is still used to label new issues and pull requests. +- **Saved first.** The Worker checks the webhook signature and saves the event in the discussion's mailbox before anything runs. A crash or deploy loses nothing. +- **One at a time.** A mailbox runs one batch of events at a time. Events within 3 seconds of each other become one batch. +- **Handoff.** The mention callback doesn't run the agent itself. It gives the AgentSession the delivery's ID and a token, then returns a _handoff_. The mailbox keeps the delivery open, and holds later events, until the agent reports back. +- **Delivery API.** The AgentSession reports over HTTP to the Worker's own delivery routes, through a service binding: `PUT …/plan` for the checklist comment, `PUT …/activity` for πŸ‘€, and `POST …/complete` or `…/fail` to finish. Channels saves each request, then shows it on GitHub, retrying if GitHub is down. -GitHub App bot identities such as `my-reviewer[bot]` are not native mentionable accounts: GitHub does not autocomplete or link `@my-reviewer[bot]`. `GITHUB_BOT_MENTION_NAME` is instead the text invocation name recognized by this provider, without the leading `@`. Use the app slug for a natural command such as `@my-reviewer`. This can still render as plain text; use `[@my-reviewer](https://github.com/apps/my-reviewer)` when a clickable link is important. If the app slug matches a real user or organization, choose a distinct invocation name to avoid notifying that account. +## The Computer: a workspace on Cloudflare -The GitHub provider reads these values while the Worker is constructed, so Alchemy binds them as Cloudflare secrets during deployment and its Durable Objects share the same bindings. The webhook secret verifies incoming requests. The App ID and private key create short-lived installation tokens for API calls. The bot user ID prevents the app from responding to its own events. Secrets are not stored in mailbox admissions or Durable Object storage. +The agent's workspace is a `@cloudflare/computer` Workspace inside the Computer Durable Object. -Optional Workers AI labeling settings (the defaults are shown): +```mermaid +flowchart TB + AS[AgentSession] -- RPC --> FS -```dotenv -GITHUB_LABEL_MODEL=@cf/cloudflare/clef -GITHUB_LABEL_THRESHOLD=0.7 -GITHUB_LABEL_TIMEOUT="60 seconds" -``` + subgraph Computer["Computer Durable Object"] + FS[(Virtual file system
+ git
in Durable Object SQLite)] + end -`@cf/cloudflare/clef-flash` is also supported. Alchemy attaches a native binding named `AI` to the host Worker, and the mailbox's callbacks use it through the captured labeler service. No separate model API key is needed; inference uses the Cloudflare account's Workers AI service and incurs its normal usage charges. The pinned PR preview contains the Workers AI fix; main's unpublished Iceberg dependency currently prevents installing the main preview. + subgraph Shell["Shell: Worker Loader"] + JB[just-bash
text tools, git, jq] + end -Restart `bun alchemy dev` after changing `.env`. For a deployed stack, deploy the updated secrets with: + subgraph Box["Container: Debian Linux"] + CD[computerd
/workspace] + T[node, bun, python,
internet] + end -```bash -bun run alchemy:deploy + FS <-- "reads and writes files directly" --> JB + FS -- "changed files before a command" --> CD + CD -- "changed files after" --> FS + CD --- T ``` -### 7. Test with a repository - -After deployment, set the GitHub App's webhook URL to `https:///integrations/github/webhook`. Open a new issue describing a clear bug, then a new PR describing a documentation change. After the three-second debounce and processing, Clef should add matching labels whose yes-probability meets the configured threshold. It considers only GitHub's nine default label names that already exist on this repository, can add multiple labels, and preserves existing labels. A null body is evaluated using the title. No confident matches means no label changes. Historical issues/PRs are not scanned automatically. - -Creation labeling does not require a maintainer mention. To test mention access separately, invoke `@` in an issue body, PR body, issue comment, PR comment, or inline review comment. Write access or higher gets eyes; other users get thumbs down and one maintainer-only notice per discussion. Creation labeling runs before a mention in the same opening batch. A failed labeling callback is logged and retried by mailbox processing; the later mention waits until that callback succeeds or the delivery terminates. +- **Files live in SQLite.** The repository's files and git objects are stored in the Durable Object's SQLite, so they persist without a disk. The agent's file tools read and write them directly. +- **The shell starts in milliseconds.** `bash` runs in just-bash, in a Worker that the Worker Loader binding starts on demand. It works on the same files, with text tools and git. +- **The container is for real programs.** `bash` with `backend: "container"` runs in a Debian container with node, bun, python, and internet access, for builds and tests. Before each command the container gets the files changed since the last one; afterwards the files the command changed are copied back. +- **Git goes through the Workspace.** Clone, fetch, pull, and push run in the Computer with the GitHub App's installation token in a request header. The token never appears in a command, a remote URL, `.git/config`, or a log. -Use Cloudflare Worker logs to look for `Workers AI label inference started`, `Workers AI label inference completed`, and `GitHub labels added`. Effect log annotations include repository, discussion number/type, model, threshold, candidate/selected labels, and each label's probability as text, such as `enhancement=0.74, question=0.41`. Creation callbacks also annotate the webhook event ID and delivery ID. Failures log `GitHub auto-label failed` with an error tag/reason; a missing runtime `AI` binding reports `binding_missing`. Request titles, bodies, credentials, and raw model responses are not logged. The `bot.github.auto_label` Effect span carries repository, number, and model attributes. This has been verified with a substituted native binding in tests, not live inference. +The container starts once, right after the first clone, so it is warm when the agent needs it. A Computer idle for 14 days is deleted with its session. -In the GitHub App settings, **Advanced β†’ Recent Deliveries** shows each webhook request, response status, and redelivery control. A successful admission returns HTTP 200. If GitHub reports 401, check the webhook secret. If callbacks fail with 403, check the app permissions and make sure the installation includes the repository. +## The agent -## The GitHub agent +The AgentSession runs a [Fold](https://github.com/humanlayer/fold) agent on OpenAI's `gpt-6.1-sol`. Each turn runs in the background, outside the request that started it, and Fold's log is kept in the object's SQLite. A turn cut off by a deploy or crash continues when the object restarts. -When someone with write access to the repository mentions the app, the mention goes to that issue's or pull request's agent session (`src/AgentSessionDO.ts`). It runs a [Fold](https://github.com/humanlayer/fold) agent on OpenAI's `gpt-6.1-sol` at medium reasoning, with the repository cloned into a Cloudflare Computer container. Each issue or pull request has one session, which keeps its conversation, workspace, and branch between mentions. A session idle for 14 days is deleted. +**What it sees.** Each request starts with the discussion: all of it the first time, then only what is new. The request itself is every comment posted with the mention, each with its ID. For a line comment that includes the file, line, diff, and review thread. -### What happens on a mention +**Where it works.** -1. The mention gets the app's `eyes` reaction, and the issue or pull request is followed so later comments reach the same session. -2. The session pulls its branch from GitHub, then sends the agent the request. The request starts with the discussion: on the first mention, the title, description, and every comment (and, on a pull request, its reviews and line comments); later, only what was posted since the agent's last turn. The app's own comments and the mentioning comment are left out. Each comment shows its ID, so the agent can reply to it. -3. A mention in a line comment on a pull request also tells the agent the file, line, diff around it, and the review thread to reply in. -4. For longer work the agent keeps a checklist comment up to date with `update_plan`. -5. When the agent finishes, its answer is posted as a comment and the `eyes` reaction is removed. If the run fails, the comment says why instead. +| Discussion | Branch | Pull request | +| ------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| Issue #42 | `humanlayer/issue-42`, created from the default branch | Opens one with `github_create_pull_request` (`Closes #42`), as a draft if it chooses | +| Pull request | The pull request's own branch, pulled on every mention | Pushing updates it. Forks are refused: the app can't push to them | -Mentions that arrive while the agent is working wait in the mailbox and run, in order, once it finishes. +**Its tools.** -### Branches +| Kind | Tools | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Files and shell | `read`, `write`, `edit`, `apply_patch`, `bash` | +| Git, with the App's token | `git_fetch`, `git_pull`, `git_merge`, `git_push` (its own branch only, never forced) | +| GitHub | `github_discussion`, `github_comments`, `github_post_comment` (with `reply_to` for review threads), and, on pull requests, the diff, checks, reviews, and line comments | +| Progress | `update_plan`, a checklist comment the agent keeps up to date | +| Web | `web_search`, `web_fetch`, and the repository's skills | -- **Issue:** the agent works on `humanlayer/issue-`, created from the default branch on the first mention and pushed to GitHub. When the work is ready, the agent opens a pull request with `github_create_pull_request`, which adds `Closes #`, or returns the one already open from the branch. -- **Pull request:** the agent works on the pull request's own branch, and pushing updates the pull request. A pull request from a fork, or whose branch was deleted, fails with a comment saying so: the app cannot push to forks. +Its final answer is posted for it. It uses `github_post_comment` for anything else, such as answering each line comment in its own thread. -Commits are by `HumanLayer Agent ` (set in `src/computer/Contract.ts`). The agent never force-pushes and never commits to the default branch. +## Labels -### Tools +New issues and pull requests are labeled by [Workers AI's Clef](https://developers.cloudflare.com/workers-ai/) model, which scores each of GitHub's default labels. A label scoring 0.7 or more is added. This needs no mention and no agent. -| Tool | What it does | -| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `read`, `write`, `edit`, `apply_patch` | Read and change files in the workspace. | -| `bash` | Run commands in the container. Long output is cut to its end; the full text is saved under `/workspace/.fold/tool-output`. | -| `web_search`, `web_fetch` | Look things up online. | -| `skill` | Load one of the repository's skills. | -| `update_plan` | Show the agent's checklist as one comment, edited as it changes. | -| `git_fetch`, `git_pull`, `git_merge`, `git_push` | Git operations that reach GitHub. `git_push` pushes only the session's branch. Local git (`status`, `diff`, `add`, `commit`) runs in `bash`. | -| `github_discussion`, `github_comments` | Read the issue or pull request and its comments. | -| `github_post_comment` | Post a comment, or, on a pull request with `reply_to`, reply in a review thread. The agent's final answer is posted for it; this is for anything else. | -| `github_pull_request_diff`, `github_pull_request_checks`, `github_pull_request_reviews`, `github_pull_request_review_comments` | Pull request sessions only: the diff, check runs, reviews, and line comments with their threads. | -| `github_create_pull_request` | Issue sessions only: open the issue's pull request, or return the open one. | - -Git and the GitHub API use the App's installation token, sent with each request. It never appears in tool input or output, environment variables, git remotes, or logs. - -### Logs - -Look for `agent_session.started` or `agent_session.resumed` (with the branch), `agent_turn.prompt_sent`, `agent_session.discussion_read`, `agent_session.plan_updated`, `workspace.exec` (with each `bash` command), and `agent_turn.finished` (with `fold.outcome`). A failed pull logs `agent_session.pull_repository failed` with its `reason`. - -## Slack setup - -Create the Slack app from [`slack-app-manifest.example.json`](slack-app-manifest.example.json) after replacing its name and request URL. [`packages/slack/README.md`](../../packages/slack/README.md) explains each scope and event. Install the app, invite the bot to your test channel, and copy these values into `.env`: - -```dotenv -SLACK_SIGNING_SECRET=... -SLACK_BOT_TOKEN=xoxb-... -``` - -Configure the Slack Events API request URL as: +## Code map ```text -https:///integrations/slack/webhook -``` - -## Whom the example listens to - -The GitHub `onMentioned` handler in `src/GithubBot.ts` checks the mentioning user's repository access before entering either the issue or PR handler. The libraries do not enforce this application policy. - -Users with `write` access or higher get an `eyes` reaction on the mentioning issue, PR, or comment. Users below `write` get a thumbs-down reaction and a maintainer-only notice once per issue or PR. Further denied mentions get only thumbs down. Only a matching notice authored by the configured bot user counts as an existing notice; another user copying it cannot suppress the reply. - -A permission lookup returning `not_found` counts as no access. Other lookup failures fail the callback without granting access. The bot's own events are dropped before callbacks run. - -## Fake remote agent - -Mention the Slack bot with `handoff [seconds] [flaky]` (for example `@bot handoff 60`; default 60, kept between 5 and 900) to try a durable handoff. The callback starts `FakeRemoteAgent` (`src/FakeRemoteAgentDO.ts`), a Durable Object that stands in for a remote agent host, posts `Handed off . Finishing in s.`, and returns `delivery.handoff()`. The mailbox stays held while the remote agent waits. When its alarm fires, the remote agent calls `GET /deliveries/`, then `POST /deliveries//complete` with the final message `Fake remote agent finished after s.`, then `GET /deliveries/` again, all with the delivery's bearer token. - -`complete` returns 202 as soon as the result and its `PresentOutcome` output are saved; the second status read shows the delivery `Finishing`, its output not yet applied (`outcome:Pending`, or `outcome:Delivering` if the alarm has already claimed it). The mailbox's alarm then posts the final message to the thread, retires the delivery, and releases the mailbox, so a queued follow-up is answered only after the final message. - -While it waits, the remote agent shows what it is doing as the delivery's activity (`PUT /deliveries//activity`): about a second after it starts it sets `Working` with `Looking into it, about s to go`, and halfway through it sets `Working` again with `Halfway there, …`. Slack shows the text as the thread's status line (`assistant.threads.setStatus`). Just before completing, the remote agent posts one lasting message (`POST /deliveries//messages`, message ID `summary`): `Summary: the fake remote agent waited s and did no real work.` The result then clears the status line. Activity is for progress that changes; messages are for output that stays. The API never exposes Slack's channel or timestamp. - -`react` makes the remote agent use portable reactions (`PUT /deliveries//reactions/`): when it shows its first activity it adds the bot's `rocket` reaction to what started the delivery, halfway through it removes it, and after posting its summary it adds `heart` to the summary. It sends each request twice; the second answers `already_recorded` and changes nothing. It skips a target the delivery does not list in `reactionTargets`, such as a Linear session's messages. `react` works on Slack, GitHub, and Linear, and combines with the other words, as in `handoff 30 react`. - -`plan` makes the remote agent keep a plan (`PUT /deliveries//plan`) titled `Fake remote agent plan`, with three items: `Look into it`, `Make no change`, and `Check the result`. When it shows its first activity the first item is in progress; halfway through it is done (`Nothing unusual`) and the second is in progress (`Waiting it out`); just before the summary all three are done. It sends the whole plan each time, twice; the second answers `already_recorded`. Slack shows it as one plan message in the thread, a Linear session as its Agent Plan, and GitHub and a Linear issue as one comment; each later plan edits it in place. `plan` combines with the other words, as in `handoff 30 plan`. - -`flaky` makes Slack refuse the final message for 20 seconds (`src/FlakySlackApi.ts` wraps `SlackApiLive` and fails that one post as if Slack were unreachable). Output retries on its own, after waits that double from about a second, and posts once the 20 seconds are up. Neither the callback nor the remote agent runs again. - -The remote agent calls the delivery API at the Worker's own public URL, which Alchemy binds at deploy (`Cloudflare.Worker.URL`). If the delivery API cannot be reached, the alarm fails and Cloudflare retries it. If the API refuses the request, the remote agent logs the reason and drops the job. Logs name the delivery ID, stage, and receipt status only, never the token. - -### Setup - -1. From `examples/alchemy-cloudflare`, run `bun alchemy deploy`, and note the URL it prints. -2. In a second terminal, run `bun alchemy logs --resource IngressWorker --since 10m --no-input` and leave it open. - -### Checks - -In the Slack test channel: - -- **Handoff and final message.** Post `@bot handoff 30`. Within a few seconds the bot replies `Handed off delivery:v1:…. Finishing in 30s.` The logs show `Mailbox delivery handed off; waiting for its remote worker`. About 30s later they show `Fake remote agent read delivery status` (stage `ExternalWaiting`), `Fake remote agent completed delivery` (receipt `accepted`), `Fake remote agent read delivery status after completing` (stage `Finishing`, output `outcome:Pending` or `outcome:Delivering`), then `Delivery output started` and `Delivery output applied`. The thread gets `Fake remote agent finished after 30s.` -- **Output retries on its own.** Post `@bot handoff 10 flaky`. After 10s the logs show `Example Slack API refusing a flaky post on purpose` and `Delivery output scheduled for retry` several times, with growing `retry_after_ms`, then `Delivery output applied` about 20s later, and the final message appears. `Slack new mention received` and `Fake remote agent job started` each appear once. -- **Activity and a lasting message.** Post `@bot handoff 30`. About a second after the bot's reply, the thread's status line under the bot shows `Looking into it, about 29s to go`; about 15s later it changes to `Halfway there, about 15s to go`. At 30s the thread gets `Summary: the fake remote agent waited 30s and did no real work.`, then `Fake remote agent finished after 30s.`, and the status line is gone. The logs show `Fake remote agent set its activity` twice and `Fake remote agent posted its summary message` (each `accepted`), and `Delivery output applied` for `SetActivity`, `SetActivity`, `CreateMessage`, then `PresentOutcome`. -- **Queued follow-up.** Post `@bot handoff 60` in a new thread. Right after the bot's reply, post `follow-up` in that thread. The bot doesn't react to the follow-up until the final message is posted; then the subscribed-thread `eyes` reaction appears. -- **Reactions.** Post `@bot handoff 30 react`. About a second after the bot's reply, your mention shows the bot's πŸš€ reaction; about 15s later it is gone. At 30s the summary message shows the bot's ❀️. The logs show `Fake remote agent set a reaction` three times, each with `receipt_status=accepted,already_recorded`, and `Delivery output applied` once for each `SetMessageReaction`. -- **Plan.** Post `@bot handoff 30 plan`. About a second after the bot's reply, the thread shows a plan, `Fake remote agent plan`, with `Look into it` in progress and the other two pending. About 15s later `Look into it` is done with `Nothing unusual` and `Make no change` is in progress with `Waiting it out`; the plan stays in the same message. At 30s, just before the summary, the same message shows all three done, and `Make no change` has lost its note. There is only one plan message. The logs show `Fake remote agent set its plan` three times, each with `receipt_status=accepted,already_recorded`, and `Delivery output applied` once for each `RenderPlan`. -- **Survives a redeploy.** Post `@bot handoff 240`. While it waits, run `bun alchemy deploy --force --yes`. After 240s the logs still show the remote agent's `complete` accepted and the delivery retired. -- **Bad credentials.** Copy the delivery ID from a bot reply, set `WORKER_URL` in your shell to the printed URL, then: - - ```bash - curl -i -X POST "$WORKER_URL/deliveries//complete" -H 'content-type: application/json' -d '{}' # 401: no token - curl -i -X POST "$WORKER_URL/deliveries//complete" -H 'content-type: application/json' -H 'Authorization: Bearer wrong' -d '{}' # 404 - ``` - -- Confirm no access token appears in the logs. - -### Linear - -The same fake remote agent runs Linear Agent Session turns and Linear issue deliveries. It asks each delivery what it supports (`GET /deliveries/`, `supportedOperations`), so it shows activity only where it can. - -- **Session.** A session whose prompt context, or whose issue's title or description, says `handoff [seconds] [ask]` hands its turn off, with a link named `Fake remote agent run log` that Linear shows on the session. A reply in the session thread that says `handoff [seconds] [ask]` hands that turn off too. The remote agent's `Working` activity shows as an ephemeral thought, which the next activity replaces; its summary shows as a lasting thought; and the turn ends with one `response` (`Fake remote agent finished after s.`). With `ask` it ends instead with an `elicitation`, `Which environment should I deploy to?`, offering `staging` and `production`; the reply starts a new turn. The agent reads the delivery's status at least every 5 seconds; after Stop it fails the turn with one `error` activity, `Stopped as requested.`, and the stop prompt then reaches `onAgentSessionPrompted` with `signal: stop`, which does nothing more. -- **Issue.** A new issue whose title or description says `issue-handoff [seconds]` is handed off. An issue has no activity, so the agent skips it; the summary and the final message are comments. The keyword differs so that an issue titled `handoff 30` and delegated to the app starts only the session's handoff. -- The run-log link opens `/fake-agent/runs/` on the Worker, a plain-text line naming the job's step. It never shows the token. - -Checks, in the `FAKE` team of the HumanLayer workspace. Use a `channels-live-p4-` marker in each title, and delete the test issues afterwards. People prompt a session by replying in its comment thread; the API does not let a person create activities. - -1. **Handoff, activity, a lasting thought, and one response.** Create an issue titled `channels-live-p4- handoff 30` and delegate it to the app. Within 10 seconds the session shows the thought `Working on this…` and the link `Fake remote agent run log`. About a second later the thought `Looking into it, about 29s to go` replaces it; about 15 seconds later `Halfway there, about 15s to go` replaces that. At 30 seconds the session gets the lasting thought `Summary: the fake remote agent waited 30s and did no real work.`, then the response `Fake remote agent finished after 30s.`, and its state is `complete`. No ephemeral thought is left. The logs show `Linear session turn handed off`, `Mailbox delivery handed off; waiting for its remote worker`, then `Delivery output applied` for `AddExternalLink`, `SetActivity`, `SetActivity`, `CreateMessage`, and `PresentOutcome`, in that order, each once. -2. **The run-log link.** Open the link on the session while a turn runs: it reads `Fake remote agent job for delivery:v1:…: step …, about s to go.` After the turn it reads `No job is running here. …`. -3. **The next reply is a new delivery.** Reply `thanks` in the session thread. The logs show `Linear agent session prompted` with a new `delivery_id`, and the session gets the example's local response. -4. **A question ends the turn.** Reply `handoff 20 ask`. After the thoughts and the summary, the session shows the question `Which environment should I deploy to?` with the choices `staging` and `production`, and its state is `awaitingInput`. Pick `staging`: the logs show a new `Linear agent session prompted` delivery, and the session gets the example's local response. -5. **Stop.** Reply `handoff 120`. Once `Looking into it…` shows, press Stop. Within about 5 seconds the logs show `Fake remote agent stopped because the delivery asked it to`, the session shows one `error` activity, `Stopped as requested.`, and then `Linear agent session prompted` with `prompt_signal: stop`, after which nothing more is posted. -6. **Reactions on a session.** Create an issue titled `channels-live-p4- handoff 30 react` and delegate it to the app. About a second after `Looking into it…` shows, the issue has the app's πŸš€ reaction; about 15 seconds later it is gone. The summary thought gets no ❀️: a session's messages take no reactions, so the agent skips it. The logs show `Fake remote agent set a reaction` twice, each with `receipt_status=accepted,already_recorded`. (A session started from a comment reacts on that comment instead; automated tests cover it.) -7. **Reactions on an issue delivery.** Create an issue titled `channels-live-p4- issue-handoff 30 react`, not delegated. The issue gets πŸš€, which goes about 15 seconds later; the summary comment gets ❀️. -8. **Plan on a session.** Create an issue titled `channels-live-p9- handoff 60 plan` and delegate it to the app. About a second after `Looking into it…` shows, the session shows an Agent Plan: `Look into it` in progress, the other two pending. While it waits, run `bun alchemy deploy --force --yes`. About 30 seconds in, `Look into it: Nothing unusual` is done and `Make no change: Waiting it out` is in progress; at 60 seconds all three are done, before the summary and the response. The logs show `Delivery output applied` for each `RenderPlan`, after the redeploy as before it. -9. **Plan on an issue delivery.** Create an issue titled `channels-live-p9- issue-handoff 30 plan`, not delegated. The issue gets one plan comment, which is edited halfway and again before the summary comment; no second plan comment appears. -10. **Issue delivery.** Create an issue titled `channels-live-p4- issue-handoff 20`, not delegated. The logs show `Linear issue delivery handed off`. About 20 seconds later the issue gets the comment `Summary: the fake remote agent waited 20s and did no real work.`, then the comment `Fake remote agent finished after 20s.`. The logs show no `Fake remote agent set its activity` for it. -11. Confirm no access token appears in the logs. - -### GitHub - -The same fake remote agent runs GitHub issue and pull request deliveries. Mention the app with `handoff [seconds] [ask]` in an issue or pull request comment, an inline review comment, or the body of a new issue or pull request (for example `@ handoff 30`). The callback subscribes the issue or pull request, starts the job, comments `Handed off . Finishing in s.`, and hands the delivery off. - -- **Activity is `eyes`.** GitHub has no status line, so `Working` adds the bot's `eyes` reaction to what mentioned the bot: the comment, or the issue or pull request itself when the mention was in its body. `Idle` removes it, and so does the result. The text of `Working` is not shown. Adding `eyes` that is already there, or removing it when it is already gone, changes nothing. -- **Messages are comments.** The summary is one comment on the issue or pull request, posted just before the result; the final message is one more comment. With `ask` the final comment is the question, with `- staging` and `- production` listed under it. The delivery API never exposes installation, repository, or comment IDs. -- A later comment that mentions the app runs `onMentioned` again, even in a subscribed issue or pull request. A later comment without a mention is a subscribed batch. It has nothing that started it to react to, so its delivery does not list `SetActivity`. -- `flaky` is a Slack-only test switch; a GitHub mention ignores it. - -Checks, in a test repository where the app is installed (`GITHUB_BOT_MENTION_NAME` is the name after `@`): - -1. **Handoff, `eyes` while working, a summary comment, and one final comment.** Open an issue titled `channels-live-p5-` and comment `@ handoff 30`. Within a few seconds the app comments `Handed off delivery:v1:…. Finishing in 30s.`, and about a second later your comment shows the app's `eyes` reaction. At 30 seconds the app comments `Summary: the fake remote agent waited 30s and did no real work.`, then `Fake remote agent finished after 30s.`, and the `eyes` reaction is gone. The logs show `GitHub bot mentioned`, `GitHub mention handed off`, `Mailbox delivery handed off; waiting for its remote worker`, then `Delivery output applied` for `SetActivity`, `SetActivity`, `CreateMessage`, and `PresentOutcome`, in that order, each once. Comment edits and deletes are covered by automated tests only. -2. **Pull request.** On a pull request, comment `@ handoff 20`. The same happens on the pull request's conversation: `eyes` on your comment while it works, then the summary comment and the final comment, with `eyes` gone at the end. -3. **Mention in a body.** Open an issue whose body is `@ handoff 20`. The `eyes` reaction shows on the issue itself, not on a comment, and is gone after the final comment. -4. **A question ends the delivery.** Comment `@ handoff 20 ask`. The final comment is `Which environment should I deploy to?` with `- staging` and `- production` under it, and `eyes` is gone. -5. **Queued follow-up.** Comment `@ handoff 30`, then right away comment `@ handoff 10` in the same issue. The second mention waits: its `eyes` reaction appears only after the first delivery's final comment, and then it runs like the first. -6. **`flaky` is ignored.** Comment `@ handoff 10 flaky`. The final comment is `Fake remote agent finished after 10s.` with no extra text, and the logs show no `Example Slack API refusing a flaky post on purpose`. -7. **Read access gets no response.** From an account with only read access to the repository (on a public repository, any account that is not a collaborator), comment `@ handoff 20` on an issue. Nothing is posted and no reaction appears. The logs show `GitHub bot mentioned`, then `Example ignored GitHub author without write access` with that login and `access=read`, and no `GitHub mention handed off`. A plain comment from that account in a subscribed issue gets no `eyes` reaction either. -8. **Reactions.** Comment `@ handoff 30 react`. About a second after the `eyes` reaction, your comment also gets the app's πŸš€; about 15 seconds later the πŸš€ is gone and `eyes` stays. The summary comment gets ❀️. The logs show `Fake remote agent set a reaction` three times, each with `receipt_status=accepted,already_recorded`. -9. **Plan.** Comment `@ handoff 30 plan`. About a second after the `eyes` reaction, the app comments the plan: `**Fake remote agent plan**`, with πŸ”„ `Look into it` and ⬜ for the other two. About 15 seconds later that same comment shows βœ… `Look into it: Nothing unusual` and πŸ”„ `Make no change: Waiting it out`, and before the summary all three are βœ…. There is only one plan comment; the logs show `Delivery output applied` once for each `RenderPlan`. -10. Confirm no access token appears in the logs. - -- The Worker verifies provider signatures before admitting events through typed Durable Object RPC. -- Admissions, processing state, and subscriptions use persistent SQLite-backed Durable Object storage. -- Slack and GitHub mailboxes use debounce delivery. Linear uses immediate serial delivery so Agent Session acknowledgements can meet Linear's ten-second responsiveness requirement. -- The alarm claims ordered batches and dispatches them to the matching Slack, GitHub, or Linear processor. -- GitHub App installation tokens and resolved bot identity are cached by the live GitHub API layer. -- Linear client-credentials tokens are acquired lazily, identity-verified, and cached only in the processing runtime. - -Replace the sample callbacks in `src/Bot.ts` with application behavior. Delivery timing, lease length, and attempt limits are configured there as well. - -## Logs - -Read recent Worker and Durable Object logs with: - -```bash -bun alchemy logs --resource IngressWorker --since 10m --no-input +src/ +β”œβ”€β”€ Worker.ts Ingress Worker: webhook routes and the delivery API +β”œβ”€β”€ GithubBot.ts GitHub callbacks: labels, mention access, the request, handoff +β”œβ”€β”€ DeliveryMailboxDO.ts the mailbox Durable Object +β”œβ”€β”€ AgentSessionDO.ts the agent: model, tools, system prompt, session start and resume +β”œβ”€β”€ DeliveryTurn.ts one delivery's turn: send the prompt, report back, recover after restarts +β”œβ”€β”€ DiscussionContext.ts what's new in the discussion +β”œβ”€β”€ GitHubTools.ts, GitTools.ts the agent's GitHub and git tools +β”œβ”€β”€ DeliveryPlanTool.ts update_plan +β”œβ”€β”€ BashTool.ts bash on the shell or the container +β”œβ”€β”€ Workspace.ts the AgentSession's view of its Computer +β”œβ”€β”€ AutoLabel.ts Workers AI labels +└── computer/ the Computer Durable Object, its Worker, and the container image ``` - -Alchemy currently uses Effect's readable multiline logger and `bun alchemy logs` does not have a JSON output option. Effect also provides single-line JSON logging through `Logger.layer([Logger.consoleJson])`, but applying that inside this example does not replace all of Alchemy's own logs or make the command return complete JSON. A consistent JSON view requires logger and JSON-output support in Alchemy itself. diff --git a/examples/alchemy-cloudflare/SETUP.md b/examples/alchemy-cloudflare/SETUP.md new file mode 100644 index 0000000..335fb6d --- /dev/null +++ b/examples/alchemy-cloudflare/SETUP.md @@ -0,0 +1,300 @@ +# Setting up the Cloudflare example + +How to deploy [the example](./README.md), connect it to a GitHub App, and check it by hand. The README explains what it is and how it works. + +## GitHub App setup + +GitHub Apps have an equivalent to Slack manifests, called the [GitHub App Manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest). Unlike Slack's pasteable manifest, GitHub's manifest is submitted by a web page and completed through a callback that exchanges a temporary code for the App ID, private key, and webhook secret. This example does not host that registration callback. `github-app-manifest.example.json` records the exact manifest settings, but the quickest way to test one repository is to create the app manually. + +### 1. Start or deploy the Worker + +```bash +cd examples/alchemy-cloudflare +cp .env.example .env +bun alchemy dev +``` + +You need a public HTTPS Worker URL before GitHub can deliver webhooks. The GitHub webhook URL is: + +```text +https:///integrations/github/webhook +``` + +### 2. Register the GitHub App + +Open **GitHub Settings β†’ Developer settings β†’ GitHub Apps β†’ New GitHub App**, then configure: + +- **GitHub App name:** any globally unique name. +- **Homepage URL:** your Worker URL or project homepage. +- **Webhook:** active. +- **Webhook URL:** the URL above. +- **Webhook secret:** generate a strong value, for example with `openssl rand -hex 32`. +- **Where can this GitHub App be installed?:** choose **Only on this account** for local testing. + +OAuth callback URLs, user authorization, device flow, and post-installation setup URLs are not needed. + +### 3. Repository permissions + +Configure these permissions under **Repository permissions**: + +| Permission | Access | Why | +| ------------- | -------------- | -------------------------------------------------------------------------------------------------- | +| Metadata | Read-only | Repository identity; GitHub grants this mandatory permission to installed apps. | +| Issues | Read and write | Read and change issues, issue comments, and labels shared by issues and PRs. | +| Pull requests | Read and write | Read, open, and change PRs, files, commits, conversation and review comments, reviews, and labels. | +| Checks | Read-only | Receive completed check-run events; list check runs; read check output and annotations. | +| Contents | Read and write | Clone, pull, and push branches, and merge pull requests. | +| Actions | Read-only | Resolve GitHub Actions-backed checks to jobs, read job details, and download job logs. | + +No Administration, organization, or account permissions are required. + +If you change permissions after installing the app, approve the new permission request for the installation or reinstall the app before testing again. + +The example manifest grants every permission needed by the package's resource methods. If you only consume callbacks and call read methods, Issues and Pull requests can be read-only. Omit Contents when the application cannot merge and Actions when it cannot inspect GitHub Actions jobs or logs. The included sample posts comments and reactions, so it needs Issues and Pull requests write access; it does not itself merge PRs or download logs. + +### 4. Subscribe to events + +Enable exactly these events under **Subscribe to events**: + +| GitHub setting | Webhook name | Used for | +| --------------------------- | ----------------------------- | ------------------------------------------------------------------------------------ | +| Issues | `issues` | Open, edit, close, reopen, assignment, and label activity. | +| Issue comment | `issue_comment` | Issue and PR conversation comments, including mentions. | +| Pull request | `pull_request` | PR lifecycle, assignment, labels, synchronization, draft state, and review requests. | +| Pull request review | `pull_request_review` | Submitted, edited, and dismissed reviews. | +| Pull request review comment | `pull_request_review_comment` | Inline review-comment creation, edits, deletion, and mentions. | +| Pull request review thread | `pull_request_review_thread` | Review-thread resolution and reopening. | +| Check run | `check_run` | Completed checks, fanned out to every associated PR. | + +The provider ignores unsupported actions, and it ignores check runs that are not associated with a pull request. `check_suite`, `push`, `workflow_run`, and other events are not needed. + +### 5. Generate a private key and install the app + +After creating the app: + +1. On the app settings page, note the numeric **App ID**. +2. Under **Private keys**, generate and download a private key. +3. Open **Install App**, install it on your test account, and grant it access to the repository you want to test. +4. Find the app slug in its settings URL or public URL. The bot login is `[bot]`. +5. Resolve the bot's numeric user ID: + +```bash +curl --fail --silent "https://api.github.com/users/%5Bbot%5D" | jq .id +``` + +### 6. Configure the example + +Put these values in `examples/alchemy-cloudflare/.env`: + +```dotenv +GITHUB_WEBHOOK_SECRET=the-secret-entered-in-the-github-app-settings +GITHUB_APP_ID=123456 +GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- +paste-the-complete-downloaded-key-here +-----END RSA PRIVATE KEY-----" +GITHUB_BOT_MENTION_NAME=your-app-slug +GITHUB_BOT_USER_ID=123456789 +OPENAI_API_KEY=sk-... +``` + +The agent runs on OpenAI's `gpt-6.1-sol` at medium reasoning, using `OPENAI_API_KEY`. Workers AI is still used to label new issues and pull requests. + +GitHub App bot identities such as `my-reviewer[bot]` are not native mentionable accounts: GitHub does not autocomplete or link `@my-reviewer[bot]`. `GITHUB_BOT_MENTION_NAME` is instead the text invocation name recognized by this provider, without the leading `@`. Use the app slug for a natural command such as `@my-reviewer`. This can still render as plain text; use `[@my-reviewer](https://github.com/apps/my-reviewer)` when a clickable link is important. If the app slug matches a real user or organization, choose a distinct invocation name to avoid notifying that account. + +The GitHub provider reads these values while the Worker is constructed, so Alchemy binds them as Cloudflare secrets during deployment and its Durable Objects share the same bindings. The webhook secret verifies incoming requests. The App ID and private key create short-lived installation tokens for API calls. The bot user ID prevents the app from responding to its own events. Secrets are not stored in mailbox admissions or Durable Object storage. + +Optional Workers AI labeling settings (the defaults are shown): + +```dotenv +GITHUB_LABEL_MODEL=@cf/cloudflare/clef +GITHUB_LABEL_THRESHOLD=0.7 +GITHUB_LABEL_TIMEOUT="60 seconds" +``` + +`@cf/cloudflare/clef-flash` is also supported. Alchemy attaches a native binding named `AI` to the host Worker, and the mailbox's callbacks use it through the captured labeler service. No separate model API key is needed; inference uses the Cloudflare account's Workers AI service and incurs its normal usage charges. The pinned PR preview contains the Workers AI fix; main's unpublished Iceberg dependency currently prevents installing the main preview. + +Restart `bun alchemy dev` after changing `.env`. For a deployed stack, deploy the updated secrets with: + +```bash +bun run alchemy:deploy +``` + +### 7. Test with a repository + +After deployment, set the GitHub App's webhook URL to `https:///integrations/github/webhook`. Open a new issue describing a clear bug, then a new PR describing a documentation change. After the three-second debounce and processing, Clef should add matching labels whose yes-probability meets the configured threshold. It considers only GitHub's nine default label names that already exist on this repository, can add multiple labels, and preserves existing labels. A null body is evaluated using the title. No confident matches means no label changes. Historical issues/PRs are not scanned automatically. + +Creation labeling does not require a maintainer mention. To test mention access separately, invoke `@` in an issue body, PR body, issue comment, PR comment, or inline review comment. Write access or higher gets eyes; other users get thumbs down and one maintainer-only notice per discussion. Creation labeling runs before a mention in the same opening batch. A failed labeling callback is logged and retried by mailbox processing; the later mention waits until that callback succeeds or the delivery terminates. + +Use Cloudflare Worker logs to look for `Workers AI label inference started`, `Workers AI label inference completed`, and `GitHub labels added`. Effect log annotations include repository, discussion number/type, model, threshold, candidate/selected labels, and each label's probability as text, such as `enhancement=0.74, question=0.41`. Creation callbacks also annotate the webhook event ID and delivery ID. Failures log `GitHub auto-label failed` with an error tag/reason; a missing runtime `AI` binding reports `binding_missing`. Request titles, bodies, credentials, and raw model responses are not logged. The `bot.github.auto_label` Effect span carries repository, number, and model attributes. This has been verified with a substituted native binding in tests, not live inference. + +In the GitHub App settings, **Advanced β†’ Recent Deliveries** shows each webhook request, response status, and redelivery control. A successful admission returns HTTP 200. If GitHub reports 401, check the webhook secret. If callbacks fail with 403, check the app permissions and make sure the installation includes the repository. + +## Logs + +Read recent Worker and Durable Object logs with: + +```bash +bun alchemy logs --resource IngressWorker --since 10m --no-input +``` + +Alchemy currently uses Effect's readable multiline logger and `bun alchemy logs` does not have a JSON output option. Effect also provides single-line JSON logging through `Logger.layer([Logger.consoleJson])`, but applying that inside this example does not replace all of Alchemy's own logs or make the command return complete JSON. A consistent JSON view requires logger and JSON-output support in Alchemy itself. + +### Agent logs + +Look for `agent_session.started` or `agent_session.resumed` (with the branch), `agent_turn.prompt_sent`, `agent_session.discussion_read`, `agent_session.plan_updated`, `workspace.exec` (with each `bash` command), and `agent_turn.finished` (with `fold.outcome`). A failed pull logs `agent_session.pull_repository failed` with its `reason`. + +## Other providers + +The Worker also has Slack and Linear integrations and a fake remote agent for trying handoffs. They are not part of the current GitHub deployment. + +### Linear Application setup + +Create a Linear Application for the workspace with the scopes and webhook categories listed in [`packages/linear/README.md`](../../packages/linear/README.md), and set its webhook URL to: + +```text +https:///integrations/linear/webhook +``` + +Use the app actor with either an application developer token: + +```dotenv +LINEAR_WEBHOOK_SECRET=... +LINEAR_DEVELOPER_TOKEN=... +LINEAR_ORGANIZATION_ID=... +LINEAR_APP_USER_ID=... +``` + +or OAuth client credentials: + +```dotenv +LINEAR_WEBHOOK_SECRET=... +LINEAR_CLIENT_ID=... +LINEAR_CLIENT_SECRET=... +LINEAR_ORGANIZATION_ID=... +LINEAR_APP_USER_ID=... +``` + +When both authentication forms are configured, `LINEAR_DEVELOPER_TOKEN` takes precedence. The configured organization and app-user IDs are explicit identity expectations, and both authentication paths verify them with a lazy `viewer` query before the first provider operation. Client credentials acquire and renew short-lived tokens automatically; a developer token remains caller-managed. No credential is placed in webhook admissions or Durable Object mailbox state. + +`AgentSessionEvent.created` and `AgentSessionEvent.prompted` are the authoritative agent entry points. Created sessions receive an automatic ephemeral thought before application code runs; both example callbacks then emit a terminal response activity so the Linear card completes. The corresponding Inbox Notification mention and assignment events are still authenticated and decoded, but are acknowledged as supplemental signals instead of starting duplicate work. + +The automatic thought, and every activity a handed-off session turn posts through the delivery API, carries its own UUID. Linear refuses a second activity with an ID it has seen, and the package counts that refusal as done, so a retry never posts one twice. Activities a callback posts itself (`session.thought`, `session.respond`) let Linear choose the ID and stay at-least-once: a retry after one succeeded can post it again. + +For a live check, use a unique `channels-live-p2-` marker: mention the app on one issue and delegate a second issue to it. Each action should create one session-scoped mailbox, show an ephemeral thought within ten seconds, invoke `onAgentSessionCreated` once, and finish with the example's terminal response. Send a follow-up message in the session and confirm `onAgentSessionPrompted` uses the same mailbox and produces one response. Corresponding Inbox Notification deliveries must not invoke the legacy mention or assignment callbacks. Logs include only organization, session, issue, delivery, and prompt activity IDsβ€”never prompts, guidance, credentials, signatures, or payload bodies. + +### Slack setup + +Create the Slack app from [`slack-app-manifest.example.json`](slack-app-manifest.example.json) after replacing its name and request URL. [`packages/slack/README.md`](../../packages/slack/README.md) explains each scope and event. Install the app, invite the bot to your test channel, and copy these values into `.env`: + +```dotenv +SLACK_SIGNING_SECRET=... +SLACK_BOT_TOKEN=xoxb-... +``` + +Configure the Slack Events API request URL as: + +```text +https:///integrations/slack/webhook +``` + +### Whom the example listens to + +The GitHub `onMentioned` handler in `src/GithubBot.ts` checks the mentioning user's repository access before entering either the issue or PR handler. The libraries do not enforce this application policy. + +Users with `write` access or higher get an `eyes` reaction on the mentioning issue, PR, or comment. Users below `write` get a thumbs-down reaction and a maintainer-only notice once per issue or PR. Further denied mentions get only thumbs down. Only a matching notice authored by the configured bot user counts as an existing notice; another user copying it cannot suppress the reply. + +A permission lookup returning `not_found` counts as no access. Other lookup failures fail the callback without granting access. The bot's own events are dropped before callbacks run. + +### Fake remote agent + +Mention the Slack bot with `handoff [seconds] [flaky]` (for example `@bot handoff 60`; default 60, kept between 5 and 900) to try a durable handoff. The callback starts `FakeRemoteAgent` (`src/FakeRemoteAgentDO.ts`), a Durable Object that stands in for a remote agent host, posts `Handed off . Finishing in s.`, and returns `delivery.handoff()`. The mailbox stays held while the remote agent waits. When its alarm fires, the remote agent calls `GET /deliveries/`, then `POST /deliveries//complete` with the final message `Fake remote agent finished after s.`, then `GET /deliveries/` again, all with the delivery's bearer token. + +`complete` returns 202 as soon as the result and its `PresentOutcome` output are saved; the second status read shows the delivery `Finishing`, its output not yet applied (`outcome:Pending`, or `outcome:Delivering` if the alarm has already claimed it). The mailbox's alarm then posts the final message to the thread, retires the delivery, and releases the mailbox, so a queued follow-up is answered only after the final message. + +While it waits, the remote agent shows what it is doing as the delivery's activity (`PUT /deliveries//activity`): about a second after it starts it sets `Working` with `Looking into it, about s to go`, and halfway through it sets `Working` again with `Halfway there, …`. Slack shows the text as the thread's status line (`assistant.threads.setStatus`). Just before completing, the remote agent posts one lasting message (`POST /deliveries//messages`, message ID `summary`): `Summary: the fake remote agent waited s and did no real work.` The result then clears the status line. Activity is for progress that changes; messages are for output that stays. The API never exposes Slack's channel or timestamp. + +`react` makes the remote agent use portable reactions (`PUT /deliveries//reactions/`): when it shows its first activity it adds the bot's `rocket` reaction to what started the delivery, halfway through it removes it, and after posting its summary it adds `heart` to the summary. It sends each request twice; the second answers `already_recorded` and changes nothing. It skips a target the delivery does not list in `reactionTargets`, such as a Linear session's messages. `react` works on Slack, GitHub, and Linear, and combines with the other words, as in `handoff 30 react`. + +`plan` makes the remote agent keep a plan (`PUT /deliveries//plan`) titled `Fake remote agent plan`, with three items: `Look into it`, `Make no change`, and `Check the result`. When it shows its first activity the first item is in progress; halfway through it is done (`Nothing unusual`) and the second is in progress (`Waiting it out`); just before the summary all three are done. It sends the whole plan each time, twice; the second answers `already_recorded`. Slack shows it as one plan message in the thread, a Linear session as its Agent Plan, and GitHub and a Linear issue as one comment; each later plan edits it in place. `plan` combines with the other words, as in `handoff 30 plan`. + +`flaky` makes Slack refuse the final message for 20 seconds (`src/FlakySlackApi.ts` wraps `SlackApiLive` and fails that one post as if Slack were unreachable). Output retries on its own, after waits that double from about a second, and posts once the 20 seconds are up. Neither the callback nor the remote agent runs again. + +The remote agent calls the delivery API at the Worker's own public URL, which Alchemy binds at deploy (`Cloudflare.Worker.URL`). If the delivery API cannot be reached, the alarm fails and Cloudflare retries it. If the API refuses the request, the remote agent logs the reason and drops the job. Logs name the delivery ID, stage, and receipt status only, never the token. + +#### Setup + +1. From `examples/alchemy-cloudflare`, run `bun alchemy deploy`, and note the URL it prints. +2. In a second terminal, run `bun alchemy logs --resource IngressWorker --since 10m --no-input` and leave it open. + +#### Checks + +In the Slack test channel: + +- **Handoff and final message.** Post `@bot handoff 30`. Within a few seconds the bot replies `Handed off delivery:v1:…. Finishing in 30s.` The logs show `Mailbox delivery handed off; waiting for its remote worker`. About 30s later they show `Fake remote agent read delivery status` (stage `ExternalWaiting`), `Fake remote agent completed delivery` (receipt `accepted`), `Fake remote agent read delivery status after completing` (stage `Finishing`, output `outcome:Pending` or `outcome:Delivering`), then `Delivery output started` and `Delivery output applied`. The thread gets `Fake remote agent finished after 30s.` +- **Output retries on its own.** Post `@bot handoff 10 flaky`. After 10s the logs show `Example Slack API refusing a flaky post on purpose` and `Delivery output scheduled for retry` several times, with growing `retry_after_ms`, then `Delivery output applied` about 20s later, and the final message appears. `Slack new mention received` and `Fake remote agent job started` each appear once. +- **Activity and a lasting message.** Post `@bot handoff 30`. About a second after the bot's reply, the thread's status line under the bot shows `Looking into it, about 29s to go`; about 15s later it changes to `Halfway there, about 15s to go`. At 30s the thread gets `Summary: the fake remote agent waited 30s and did no real work.`, then `Fake remote agent finished after 30s.`, and the status line is gone. The logs show `Fake remote agent set its activity` twice and `Fake remote agent posted its summary message` (each `accepted`), and `Delivery output applied` for `SetActivity`, `SetActivity`, `CreateMessage`, then `PresentOutcome`. +- **Queued follow-up.** Post `@bot handoff 60` in a new thread. Right after the bot's reply, post `follow-up` in that thread. The bot doesn't react to the follow-up until the final message is posted; then the subscribed-thread `eyes` reaction appears. +- **Reactions.** Post `@bot handoff 30 react`. About a second after the bot's reply, your mention shows the bot's πŸš€ reaction; about 15s later it is gone. At 30s the summary message shows the bot's ❀️. The logs show `Fake remote agent set a reaction` three times, each with `receipt_status=accepted,already_recorded`, and `Delivery output applied` once for each `SetMessageReaction`. +- **Plan.** Post `@bot handoff 30 plan`. About a second after the bot's reply, the thread shows a plan, `Fake remote agent plan`, with `Look into it` in progress and the other two pending. About 15s later `Look into it` is done with `Nothing unusual` and `Make no change` is in progress with `Waiting it out`; the plan stays in the same message. At 30s, just before the summary, the same message shows all three done, and `Make no change` has lost its note. There is only one plan message. The logs show `Fake remote agent set its plan` three times, each with `receipt_status=accepted,already_recorded`, and `Delivery output applied` once for each `RenderPlan`. +- **Survives a redeploy.** Post `@bot handoff 240`. While it waits, run `bun alchemy deploy --force --yes`. After 240s the logs still show the remote agent's `complete` accepted and the delivery retired. +- **Bad credentials.** Copy the delivery ID from a bot reply, set `WORKER_URL` in your shell to the printed URL, then: + + ```bash + curl -i -X POST "$WORKER_URL/deliveries//complete" -H 'content-type: application/json' -d '{}' # 401: no token + curl -i -X POST "$WORKER_URL/deliveries//complete" -H 'content-type: application/json' -H 'Authorization: Bearer wrong' -d '{}' # 404 + ``` + +- Confirm no access token appears in the logs. + +#### Linear + +The same fake remote agent runs Linear Agent Session turns and Linear issue deliveries. It asks each delivery what it supports (`GET /deliveries/`, `supportedOperations`), so it shows activity only where it can. + +- **Session.** A session whose prompt context, or whose issue's title or description, says `handoff [seconds] [ask]` hands its turn off, with a link named `Fake remote agent run log` that Linear shows on the session. A reply in the session thread that says `handoff [seconds] [ask]` hands that turn off too. The remote agent's `Working` activity shows as an ephemeral thought, which the next activity replaces; its summary shows as a lasting thought; and the turn ends with one `response` (`Fake remote agent finished after s.`). With `ask` it ends instead with an `elicitation`, `Which environment should I deploy to?`, offering `staging` and `production`; the reply starts a new turn. The agent reads the delivery's status at least every 5 seconds; after Stop it fails the turn with one `error` activity, `Stopped as requested.`, and the stop prompt then reaches `onAgentSessionPrompted` with `signal: stop`, which does nothing more. +- **Issue.** A new issue whose title or description says `issue-handoff [seconds]` is handed off. An issue has no activity, so the agent skips it; the summary and the final message are comments. The keyword differs so that an issue titled `handoff 30` and delegated to the app starts only the session's handoff. +- The run-log link opens `/fake-agent/runs/` on the Worker, a plain-text line naming the job's step. It never shows the token. + +Checks, in the `FAKE` team of the HumanLayer workspace. Use a `channels-live-p4-` marker in each title, and delete the test issues afterwards. People prompt a session by replying in its comment thread; the API does not let a person create activities. + +1. **Handoff, activity, a lasting thought, and one response.** Create an issue titled `channels-live-p4- handoff 30` and delegate it to the app. Within 10 seconds the session shows the thought `Working on this…` and the link `Fake remote agent run log`. About a second later the thought `Looking into it, about 29s to go` replaces it; about 15 seconds later `Halfway there, about 15s to go` replaces that. At 30 seconds the session gets the lasting thought `Summary: the fake remote agent waited 30s and did no real work.`, then the response `Fake remote agent finished after 30s.`, and its state is `complete`. No ephemeral thought is left. The logs show `Linear session turn handed off`, `Mailbox delivery handed off; waiting for its remote worker`, then `Delivery output applied` for `AddExternalLink`, `SetActivity`, `SetActivity`, `CreateMessage`, and `PresentOutcome`, in that order, each once. +2. **The run-log link.** Open the link on the session while a turn runs: it reads `Fake remote agent job for delivery:v1:…: step …, about s to go.` After the turn it reads `No job is running here. …`. +3. **The next reply is a new delivery.** Reply `thanks` in the session thread. The logs show `Linear agent session prompted` with a new `delivery_id`, and the session gets the example's local response. +4. **A question ends the turn.** Reply `handoff 20 ask`. After the thoughts and the summary, the session shows the question `Which environment should I deploy to?` with the choices `staging` and `production`, and its state is `awaitingInput`. Pick `staging`: the logs show a new `Linear agent session prompted` delivery, and the session gets the example's local response. +5. **Stop.** Reply `handoff 120`. Once `Looking into it…` shows, press Stop. Within about 5 seconds the logs show `Fake remote agent stopped because the delivery asked it to`, the session shows one `error` activity, `Stopped as requested.`, and then `Linear agent session prompted` with `prompt_signal: stop`, after which nothing more is posted. +6. **Reactions on a session.** Create an issue titled `channels-live-p4- handoff 30 react` and delegate it to the app. About a second after `Looking into it…` shows, the issue has the app's πŸš€ reaction; about 15 seconds later it is gone. The summary thought gets no ❀️: a session's messages take no reactions, so the agent skips it. The logs show `Fake remote agent set a reaction` twice, each with `receipt_status=accepted,already_recorded`. (A session started from a comment reacts on that comment instead; automated tests cover it.) +7. **Reactions on an issue delivery.** Create an issue titled `channels-live-p4- issue-handoff 30 react`, not delegated. The issue gets πŸš€, which goes about 15 seconds later; the summary comment gets ❀️. +8. **Plan on a session.** Create an issue titled `channels-live-p9- handoff 60 plan` and delegate it to the app. About a second after `Looking into it…` shows, the session shows an Agent Plan: `Look into it` in progress, the other two pending. While it waits, run `bun alchemy deploy --force --yes`. About 30 seconds in, `Look into it: Nothing unusual` is done and `Make no change: Waiting it out` is in progress; at 60 seconds all three are done, before the summary and the response. The logs show `Delivery output applied` for each `RenderPlan`, after the redeploy as before it. +9. **Plan on an issue delivery.** Create an issue titled `channels-live-p9- issue-handoff 30 plan`, not delegated. The issue gets one plan comment, which is edited halfway and again before the summary comment; no second plan comment appears. +10. **Issue delivery.** Create an issue titled `channels-live-p4- issue-handoff 20`, not delegated. The logs show `Linear issue delivery handed off`. About 20 seconds later the issue gets the comment `Summary: the fake remote agent waited 20s and did no real work.`, then the comment `Fake remote agent finished after 20s.`. The logs show no `Fake remote agent set its activity` for it. +11. Confirm no access token appears in the logs. + +#### GitHub + +The same fake remote agent runs GitHub issue and pull request deliveries. Mention the app with `handoff [seconds] [ask]` in an issue or pull request comment, an inline review comment, or the body of a new issue or pull request (for example `@ handoff 30`). The callback subscribes the issue or pull request, starts the job, comments `Handed off . Finishing in s.`, and hands the delivery off. + +- **Activity is `eyes`.** GitHub has no status line, so `Working` adds the bot's `eyes` reaction to what mentioned the bot: the comment, or the issue or pull request itself when the mention was in its body. `Idle` removes it, and so does the result. The text of `Working` is not shown. Adding `eyes` that is already there, or removing it when it is already gone, changes nothing. +- **Messages are comments.** The summary is one comment on the issue or pull request, posted just before the result; the final message is one more comment. With `ask` the final comment is the question, with `- staging` and `- production` listed under it. The delivery API never exposes installation, repository, or comment IDs. +- A later comment that mentions the app runs `onMentioned` again, even in a subscribed issue or pull request. A later comment without a mention is a subscribed batch. It has nothing that started it to react to, so its delivery does not list `SetActivity`. +- `flaky` is a Slack-only test switch; a GitHub mention ignores it. + +Checks, in a test repository where the app is installed (`GITHUB_BOT_MENTION_NAME` is the name after `@`): + +1. **Handoff, `eyes` while working, a summary comment, and one final comment.** Open an issue titled `channels-live-p5-` and comment `@ handoff 30`. Within a few seconds the app comments `Handed off delivery:v1:…. Finishing in 30s.`, and about a second later your comment shows the app's `eyes` reaction. At 30 seconds the app comments `Summary: the fake remote agent waited 30s and did no real work.`, then `Fake remote agent finished after 30s.`, and the `eyes` reaction is gone. The logs show `GitHub bot mentioned`, `GitHub mention handed off`, `Mailbox delivery handed off; waiting for its remote worker`, then `Delivery output applied` for `SetActivity`, `SetActivity`, `CreateMessage`, and `PresentOutcome`, in that order, each once. Comment edits and deletes are covered by automated tests only. +2. **Pull request.** On a pull request, comment `@ handoff 20`. The same happens on the pull request's conversation: `eyes` on your comment while it works, then the summary comment and the final comment, with `eyes` gone at the end. +3. **Mention in a body.** Open an issue whose body is `@ handoff 20`. The `eyes` reaction shows on the issue itself, not on a comment, and is gone after the final comment. +4. **A question ends the delivery.** Comment `@ handoff 20 ask`. The final comment is `Which environment should I deploy to?` with `- staging` and `- production` under it, and `eyes` is gone. +5. **Queued follow-up.** Comment `@ handoff 30`, then right away comment `@ handoff 10` in the same issue. The second mention waits: its `eyes` reaction appears only after the first delivery's final comment, and then it runs like the first. +6. **`flaky` is ignored.** Comment `@ handoff 10 flaky`. The final comment is `Fake remote agent finished after 10s.` with no extra text, and the logs show no `Example Slack API refusing a flaky post on purpose`. +7. **Read access gets no response.** From an account with only read access to the repository (on a public repository, any account that is not a collaborator), comment `@ handoff 20` on an issue. Nothing is posted and no reaction appears. The logs show `GitHub bot mentioned`, then `Example ignored GitHub author without write access` with that login and `access=read`, and no `GitHub mention handed off`. A plain comment from that account in a subscribed issue gets no `eyes` reaction either. +8. **Reactions.** Comment `@ handoff 30 react`. About a second after the `eyes` reaction, your comment also gets the app's πŸš€; about 15 seconds later the πŸš€ is gone and `eyes` stays. The summary comment gets ❀️. The logs show `Fake remote agent set a reaction` three times, each with `receipt_status=accepted,already_recorded`. +9. **Plan.** Comment `@ handoff 30 plan`. About a second after the `eyes` reaction, the app comments the plan: `**Fake remote agent plan**`, with πŸ”„ `Look into it` and ⬜ for the other two. About 15 seconds later that same comment shows βœ… `Look into it: Nothing unusual` and πŸ”„ `Make no change: Waiting it out`, and before the summary all three are βœ…. There is only one plan comment; the logs show `Delivery output applied` once for each `RenderPlan`. +10. Confirm no access token appears in the logs. + +- The Worker verifies provider signatures before admitting events through typed Durable Object RPC. +- Admissions, processing state, and subscriptions use persistent SQLite-backed Durable Object storage. +- Slack and GitHub mailboxes use debounce delivery. Linear uses immediate serial delivery so Agent Session acknowledgements can meet Linear's ten-second responsiveness requirement. +- The alarm claims ordered batches and dispatches them to the matching Slack, GitHub, or Linear processor. +- GitHub App installation tokens and resolved bot identity are cached by the live GitHub API layer. +- Linear client-credentials tokens are acquired lazily, identity-verified, and cached only in the processing runtime. + +Replace the sample callbacks in `src/GithubBot.ts` with application behavior. Delivery timing, lease length, and attempt limits are configured there as well. diff --git a/examples/alchemy-cloudflare/src/AgentSessionDO.ts b/examples/alchemy-cloudflare/src/AgentSessionDO.ts index 7ee6565..6d6c79d 100644 --- a/examples/alchemy-cloudflare/src/AgentSessionDO.ts +++ b/examples/alchemy-cloudflare/src/AgentSessionDO.ts @@ -277,7 +277,7 @@ const AgentConversationLive = Layer.effect( discussion: message.githubDiscussion, seen, botUserId, - mentionedIn: message.mentionedIn, + requestComments: message.requestComments ?? [], }) yield* Effect.logInfo('agent_session.discussion_read').pipe( Effect.annotateLogs({ first: Option.isNone(seen), characters: context.text.length }), diff --git a/examples/alchemy-cloudflare/src/DeliveryTurn.ts b/examples/alchemy-cloudflare/src/DeliveryTurn.ts index 119f875..b6b535f 100644 --- a/examples/alchemy-cloudflare/src/DeliveryTurn.ts +++ b/examples/alchemy-cloudflare/src/DeliveryTurn.ts @@ -36,7 +36,7 @@ import { } from 'effect' import { DeliveryApi } from './DeliveryApi' -import { MentionedIn } from './DiscussionContext' +import { RequestComment } from './DiscussionContext' import { RunRecovery } from './SessionRecovery' import type { RepoCloneError } from './Workspace' @@ -51,8 +51,8 @@ const WORKING = DeliveryActivity.cases.Working.make({ message: 'Working on it' } export const AgentSessionMessage = Schema.Struct({ prompt: Schema.String, githubDiscussion: Schema.Union([GitHubIssue, GitHubPullRequest]), - /** The comment that mentioned the bot, if a comment did. */ - mentionedIn: Schema.optional(MentionedIn), + /** The comments the prompt is made of: the one that mentioned the bot and the others in its batch. */ + requestComments: Schema.optional(Schema.Array(RequestComment)), deliveryId: DeliveryId, accessToken: Schema.RedactedFromValue(Schema.String), }) diff --git a/examples/alchemy-cloudflare/src/DiscussionContext.ts b/examples/alchemy-cloudflare/src/DiscussionContext.ts index d7df0dc..946840a 100644 --- a/examples/alchemy-cloudflare/src/DiscussionContext.ts +++ b/examples/alchemy-cloudflare/src/DiscussionContext.ts @@ -21,12 +21,12 @@ const MAX_BODY_LENGTH = 4_000 /** Most text shown at once; the oldest comments are left out past this. */ const MAX_CONTEXT_LENGTH = 60_000 -/** The comment that mentioned the bot; it is the request, so it is not repeated as context. */ -export const MentionedIn = Schema.TaggedUnion({ +/** A comment the request is made of; it is shown as the request, so it is not repeated as context. */ +export const RequestComment = Schema.TaggedUnion({ Comment: { id: GitHubId }, ReviewComment: { id: GitHubId }, }) -export type MentionedIn = typeof MentionedIn.Type +export type RequestComment = typeof RequestComment.Type /** The highest comment, review, and line comment IDs the agent has been shown. */ export const DiscussionSeen = Schema.Struct({ @@ -109,16 +109,16 @@ const render = (heading: string, sections: ReadonlyArray readonly botUserId: number - readonly mentionedIn: MentionedIn | undefined + readonly requestComments: ReadonlyArray }) { - const { discussion, botUserId, mentionedIn } = input + const { discussion, botUserId, requestComments } = input const seen = Option.getOrElse(input.seen, (): DiscussionSeen => ({ comment: 0, review: 0, @@ -126,8 +126,8 @@ export const readDiscussionContext = Effect.fn('agent_session.read_discussion')( })) const first = Option.isNone(input.seen) const notBot = (participant: GitHubParticipant | null) => participant?.id !== botUserId - const isMention = (tag: MentionedIn['_tag'], id: number) => - Predicate.isTagged(mentionedIn, tag) && mentionedIn.id === id + const isMention = (tag: RequestComment['_tag'], id: number) => + requestComments.some((comment) => Predicate.isTagged(comment, tag) && comment.id === id) const allComments = yield* discussion.listComments() const comments = allComments.filter((comment) => notBot(comment.author) && !isMention('Comment', comment.ref.id)) diff --git a/examples/alchemy-cloudflare/src/GitHubTools.ts b/examples/alchemy-cloudflare/src/GitHubTools.ts index 0068601..98ee900 100644 --- a/examples/alchemy-cloudflare/src/GitHubTools.ts +++ b/examples/alchemy-cloudflare/src/GitHubTools.ts @@ -226,7 +226,7 @@ export interface IssuePullRequestTarget { */ export const openIssuePullRequest = Effect.fn('agent_session.open_issue_pull_request')(function* ( target: IssuePullRequestTarget, - request: { readonly title: string; readonly body: string }, + request: { readonly title: string; readonly body: string; readonly draft?: boolean | undefined }, ) { const { issue, branch, base } = target const api = yield* GitHubApi @@ -238,14 +238,16 @@ export const openIssuePullRequest = Effect.fn('agent_session.open_issue_pull_req }) const [open] = yield* api.listPullRequestsForBranch({ repository, head: branch }) if (Predicate.isNotUndefined(open)) { - return ToolResultText.make({ text: `A pull request from ${branch} is already open: ${open.url}` }) + const kind = open.draft ? 'A draft pull request' : 'A pull request' + return ToolResultText.make({ text: `${kind} from ${branch} is already open: ${open.url}` }) } const closes = `Closes #${issue.ref.number}` const body = request.body.includes(closes) ? request.body : [request.body.trim(), closes].filter((part) => part.length > 0).join('\n\n') - const created = yield* api.createPullRequest({ repository, head: branch, base, title: request.title, body }) - return ToolResultText.make({ text: `Opened ${created.url}` }) + const draft = request.draft === true + const created = yield* api.createPullRequest({ repository, head: branch, base, title: request.title, body, draft }) + return ToolResultText.make({ text: `Opened ${draft ? 'a draft pull request' : 'a pull request'}: ${created.url}` }) }) /** The tool that opens the issue's pull request, or returns the one already open. */ @@ -258,6 +260,11 @@ export const createPullRequestTool = (target: IssuePullRequestTarget): FoldTool< body: Schema.String.annotate({ description: `The pull request description, in GitHub Markdown. "Closes #${target.issue.ref.number}" is added if it is missing.`, }), + draft: Schema.optionalKey( + Schema.Boolean.annotate({ + description: 'Open it as a draft, not yet ready for review. Defaults to false.', + }), + ), }), success: ToolResultText, failure: ToolResultFailure, diff --git a/examples/alchemy-cloudflare/src/GithubBot.ts b/examples/alchemy-cloudflare/src/GithubBot.ts index 411f0f1..e17eae6 100644 --- a/examples/alchemy-cloudflare/src/GithubBot.ts +++ b/examples/alchemy-cloudflare/src/GithubBot.ts @@ -21,19 +21,21 @@ import { hasGitHubAccess, type GitHubApiError, type GitHubCallbackHandlers, + type GitHubIssueComment, type GitHubIssueCreated, type GitHubMentioned, + type GitHubParticipant, type GitHubPrCreated, type GitHubRepositoryRef, type GitHubReviewComment, } from '@humanlayer/channels-github' import type { RuntimeContext } from 'alchemy/RuntimeContext' -import { Config, Effect, Match, Predicate, Schema } from 'effect' +import { Array as Arr, Config, Effect, Match, Predicate, Schema } from 'effect' import { AgentSessions } from './AgentSessionDO' import { AutoLabel, type AutoLabelError } from './AutoLabel' import { AgentSessionMessage } from './DeliveryTurn' -import { MentionedIn } from './DiscussionContext' +import { RequestComment } from './DiscussionContext' export const maintainerOnlyNotice = 'This agent can only be invoked by maintainers (users with write access or higher to this repository).' @@ -100,44 +102,78 @@ export const respondToMentionAccess = Effect.fn('bot.github.respondToMentionAcce /** Lines of text, without the missing ones, as one string. */ const joinText = (lines: ReadonlyArray) => lines.filter(Predicate.isNotNull).join('\n') -/** - * A line comment as the agent's request: where it is in the diff, and which comment to reply to, then its text. - * GitHub threads replies under the thread's first comment. - */ -const reviewCommentRequest = (comment: GitHubReviewComment) => { +/** One comment of the request: which comment it is, its own text, and how the agent is shown it. */ +interface RequestItem { + readonly comment: RequestComment + readonly body: string + readonly text: string +} + +const authorOf = (author: GitHubParticipant | null) => (author === null ? 'a deleted user' : `@${author.login}`) + +const commentItem = (comment: GitHubIssueComment): RequestItem => ({ + comment: RequestComment.cases.Comment.make({ id: comment.ref.id }), + body: comment.body, + text: `${authorOf(comment.author)} commented (comment ${comment.ref.id}):\n${comment.body}`, +}) + +/** A line comment with where it is in the diff and the thread to reply in: GitHub threads replies under the first. */ +const lineCommentItem = (comment: GitHubReviewComment): RequestItem => { const line = comment.line ?? comment.startLine const where = Predicate.isNullish(line) ? comment.path : `${comment.path}:${line}` const thread = comment.inReplyToId ?? comment.ref.id - return [ - `This request is line comment ${comment.ref.id} on ${where}, in the review thread of line comment ${thread}. To reply in that thread, use github_post_comment with reply_to ${thread}. The diff around it:\n\`\`\`diff\n${comment.diffHunk}\n\`\`\``, - '', - comment.body, - ].join('\n') + return { + comment: RequestComment.cases.ReviewComment.make({ id: comment.ref.id }), + body: comment.body, + text: `${authorOf(comment.author)} commented on ${where} (line comment ${comment.ref.id}, thread ${thread}):\n\`\`\`diff\n${comment.diffHunk}\n\`\`\`\n${comment.body}`, + } } -/** The agent's request: the comment that mentioned the bot, or the issue or pull request that was opened. */ -const gitHubMentionText = (event: GitHubMentioned) => - Match.value(event.trigger).pipe( - Match.tagsExhaustive({ - GitHubIssueOpened: ({ title, body }) => joinText([title, body]), - GitHubPrOpened: ({ title, body }) => joinText([title, body]), - GitHubIssueCommentCreated: ({ comment }) => comment.body, - GitHubPrCommentCreated: ({ comment }) => comment.body, - GitHubPrReviewCommentCreated: ({ comment }) => reviewCommentRequest(comment), +/** + * The comments a mention's batch posted, the mentioning one first, each once. Other events add nothing to + * the request. + */ +const requestItems = (event: GitHubMentioned): ReadonlyArray => + Arr.dedupeWith( + [event.trigger, ...event.events].flatMap((item): ReadonlyArray => { + if ( + Predicate.isTagged(item, 'GitHubIssueCommentCreated') || + Predicate.isTagged(item, 'GitHubPrCommentCreated') + ) + return [commentItem(item.comment)] + if (Predicate.isTagged(item, 'GitHubPrReviewCommentCreated')) return [lineCommentItem(item.comment)] + return [] }), + (a, b) => a.comment._tag === b.comment._tag && a.comment.id === b.comment.id, ) -/** The comment that mentioned the bot; none when the issue or pull request itself did. */ -const gitHubMentionedIn = (event: GitHubMentioned): MentionedIn | undefined => - Match.value(event.trigger).pipe( - Match.tagsExhaustive({ - GitHubIssueOpened: () => undefined, - GitHubPrOpened: () => undefined, - GitHubIssueCommentCreated: ({ comment }) => MentionedIn.cases.Comment.make({ id: comment.ref.id }), - GitHubPrCommentCreated: ({ comment }) => MentionedIn.cases.Comment.make({ id: comment.ref.id }), - GitHubPrReviewCommentCreated: ({ comment }) => MentionedIn.cases.ReviewComment.make({ id: comment.ref.id }), +/** + * The agent's request. A lone comment is its text, and an opened issue or pull request is its title and + * description. Otherwise, such as several comments posted together or a line comment, each comment is listed + * with what the agent needs to answer it, and the agent is told to answer each line comment in its own thread. + */ +export const gitHubRequest = (event: GitHubMentioned) => { + const items = requestItems(event) + const opened = Match.value(event.trigger).pipe( + Match.tags({ + GitHubIssueOpened: ({ title, body }) => joinText([title, body]), + GitHubPrOpened: ({ title, body }) => joinText([title, body]), }), + Match.orElse(() => null), ) + const [first] = items + if (Predicate.isNull(opened) && items.length === 1 && Predicate.isTagged(first?.comment, 'Comment')) + return first.body + if (Arr.isReadonlyArrayEmpty(items)) return opened ?? '' + const guide = items.some(({ comment }) => Predicate.isTagged(comment, 'ReviewComment')) + ? 'Answer each line comment in its own review thread with github_post_comment, setting reply_to to its thread. Your final answer is posted on the pull request, so keep it to a short summary.' + : 'Answer them in your final answer.' + return joinText([ + opened, + `These comments were posted together, and they are the request. ${guide}`, + items.map(({ text }) => text).join('\n\n---\n\n'), + ]) +} /** * An authorized mention subscribes the discussion, so later comments and checks reach this mailbox, and @@ -202,9 +238,9 @@ export const githubHandlers = { yield* discussion.subscribe() const message = yield* Schema.encodeEffect(AgentSessionMessage)( AgentSessionMessage.make({ - prompt: gitHubMentionText(event), + prompt: gitHubRequest(event), githubDiscussion: discussion, - mentionedIn: gitHubMentionedIn(event), + requestComments: requestItems(event).map(({ comment }) => comment), deliveryId: context.deliveryId, accessToken: context.accessToken, }), diff --git a/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts b/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts index dbe5a03..cbeb8b3 100644 --- a/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts +++ b/examples/alchemy-cloudflare/test/discussion-context.vi.test.ts @@ -15,7 +15,7 @@ import { } from '@humanlayer/channels-github' import { Effect, Layer, Option } from 'effect' -import { MentionedIn, readDiscussionContext, type DiscussionSeen } from '../src/DiscussionContext' +import { RequestComment, readDiscussionContext, type DiscussionSeen } from '../src/DiscussionContext' const repository = { installationId: GitHubId.make(100), @@ -129,13 +129,13 @@ const api = Layer.mock(GitHubApi, { const read = (input: { readonly discussion: GitHubIssue | GitHubPullRequest readonly seen?: DiscussionSeen - readonly mentionedIn?: MentionedIn + readonly requestComments?: ReadonlyArray }) => { const context = readDiscussionContext({ discussion: input.discussion, seen: Option.fromNullishOr(input.seen), botUserId: BOT_USER_ID, - mentionedIn: input.mentionedIn, + requestComments: input.requestComments ?? [], }) return Effect.provide(context, api) } @@ -145,7 +145,7 @@ describe('readDiscussionContext', () => { Effect.gen(function* () { const context = yield* read({ discussion: issue, - mentionedIn: MentionedIn.cases.Comment.make({ id: GitHubId.make(13) }), + requestComments: [RequestComment.cases.Comment.make({ id: GitHubId.make(13) })], }) expect(context.text).toBe( @@ -206,7 +206,7 @@ describe('readDiscussionContext', () => { Effect.gen(function* () { const context = yield* read({ discussion: pullRequest, - mentionedIn: MentionedIn.cases.ReviewComment.make({ id: GitHubId.make(41) }), + requestComments: [RequestComment.cases.ReviewComment.make({ id: GitHubId.make(41) })], }) expect(context.text).toBe( diff --git a/examples/alchemy-cloudflare/test/github-request.vi.test.ts b/examples/alchemy-cloudflare/test/github-request.vi.test.ts new file mode 100644 index 0000000..2ce8b62 --- /dev/null +++ b/examples/alchemy-cloudflare/test/github-request.vi.test.ts @@ -0,0 +1,92 @@ +import { describe, it } from '@effect/vitest' +import { + GitHubEventId, + GitHubId, + GitHubIssueComment, + GitHubParticipant, + GitHubPrCommentCreated, + GitHubPrMentioned, + GitHubPrReviewCommentCreated, + GitHubPullRequest, + GitHubPullRequestRef, + GitHubReviewComment, +} from '@humanlayer/channels-github' + +import { gitHubRequest } from '../src/GithubBot' + +const pullRequest = GitHubPullRequest.make({ + ref: GitHubPullRequestRef.make({ + installationId: GitHubId.make(100), + repositoryId: GitHubId.make(200), + owner: 'humanlayer', + repository: 'effect-channels', + number: GitHubId.make(43), + }), + mailboxKey: 'github:pr:43', +}) +const alice = GitHubParticipant.make({ id: GitHubId.make(1), login: 'alice', type: 'User' }) + +const lineComment = (id: number, line: number, body: string, inReplyTo?: number) => + GitHubPrReviewCommentCreated.make({ + pullRequest, + actor: alice, + eventId: GitHubEventId.make(`line-comment-${id}`), + comment: GitHubReviewComment.make({ + ref: { pullRequest: pullRequest.ref, id: GitHubId.make(id) }, + nodeId: `review-comment-${id}`, + body, + url: `https://github.com/humanlayer/effect-channels/pull/43#discussion_r${id}`, + author: alice, + reviewId: GitHubId.make(600), + path: 'src/app.ts', + commitId: 'abc', + originalCommitId: 'abc', + diffHunk: `@@ -${line} +${line} @@`, + line, + inReplyToId: inReplyTo === undefined ? null : GitHubId.make(inReplyTo), + }), + }) + +describe('gitHubRequest', () => { + it('lists every line comment in the batch, each with the thread to answer it in', ({ expect }) => { + const first = lineComment(701, 12, '@agent why is this null?') + const second = lineComment(702, 30, '@agent and rename this', 650) + const event = GitHubPrMentioned.make({ pullRequest, trigger: first, events: [second] }) + + expect(gitHubRequest(event)).toBe( + [ + 'These comments were posted together, and they are the request. Answer each line comment in its own review thread with github_post_comment, setting reply_to to its thread. Your final answer is posted on the pull request, so keep it to a short summary.', + '@alice commented on src/app.ts:12 (line comment 701, thread 701):', + '```diff', + '@@ -12 +12 @@', + '```', + '@agent why is this null?', + '', + '---', + '', + '@alice commented on src/app.ts:30 (line comment 702, thread 650):', + '```diff', + '@@ -30 +30 @@', + '```', + '@agent and rename this', + ].join('\n'), + ) + }) + + it('keeps a lone comment as it is', ({ expect }) => { + const comment = GitHubPrCommentCreated.make({ + pullRequest, + actor: alice, + eventId: GitHubEventId.make('comment-800'), + comment: GitHubIssueComment.make({ + ref: { discussion: { _tag: 'PullRequest', ref: pullRequest.ref }, id: GitHubId.make(800) }, + body: '@agent what does this do?', + url: 'https://github.com/humanlayer/effect-channels/pull/43#issuecomment-800', + author: alice, + }), + }) + expect(gitHubRequest(GitHubPrMentioned.make({ pullRequest, trigger: comment, events: [] }))).toBe( + '@agent what does this do?', + ) + }) +}) diff --git a/examples/alchemy-cloudflare/test/mention-access.vi.test.ts b/examples/alchemy-cloudflare/test/mention-access.vi.test.ts index 724bdf0..5c27200 100644 --- a/examples/alchemy-cloudflare/test/mention-access.vi.test.ts +++ b/examples/alchemy-cloudflare/test/mention-access.vi.test.ts @@ -116,14 +116,14 @@ const fixtures = (actor = person(1)) => { }, { name: 'issue comment', - mentionedIn: { _tag: 'Comment', id: issueComment.comment.ref.id }, + requestComments: [{ _tag: 'Comment', id: issueComment.comment.ref.id }], event: GitHubIssueMentioned.make({ issue, trigger: issueComment, events: [issueComment] }), discussion: issueDiscussion, target: GitHubReactionTarget.cases.Comment.make({ comment: issueComment.comment.ref }), }, { name: 'PR comment', - mentionedIn: { _tag: 'Comment', id: prComment.comment.ref.id }, + requestComments: [{ _tag: 'Comment', id: prComment.comment.ref.id }], event: GitHubPrMentioned.make({ pullRequest, trigger: prComment, events: [prComment] }), discussion: prDiscussion, target: GitHubReactionTarget.cases.Comment.make({ comment: prComment.comment.ref }), @@ -131,14 +131,14 @@ const fixtures = (actor = person(1)) => { { name: 'review comment', prompt: [ - 'This request is line comment 701 on src/index.ts, in the review thread of line comment 701. To reply in that thread, use github_post_comment with reply_to 701. The diff around it:', + 'These comments were posted together, and they are the request. Answer each line comment in its own review thread with github_post_comment, setting reply_to to its thread. Your final answer is posted on the pull request, so keep it to a short summary.', + '@user-1 commented on src/index.ts (line comment 701, thread 701):', '```diff', '@@ -1 +1 @@', - '```', - '', + '```', '@agent help', ].join('\n'), - mentionedIn: { _tag: 'ReviewComment', id: reviewComment.comment.ref.id }, + requestComments: [{ _tag: 'ReviewComment', id: reviewComment.comment.ref.id }], event: GitHubPrMentioned.make({ pullRequest, trigger: reviewComment, events: [reviewComment] }), discussion: prDiscussion, target: GitHubReactionTarget.cases.Comment.make({ comment: reviewComment.comment.ref }), @@ -478,7 +478,7 @@ describe('the registered GitHub onMentioned handler', () => { accessToken: 'test-access-token', githubDiscussion: { mailboxKey }, }) - expect(sent?.message.mentionedIn).toEqual(fixture.mentionedIn) + expect(sent?.message.requestComments).toEqual(fixture.requestComments ?? []) expect(result.callback.handoffsBeforeSend).toEqual([0]) expect(result.handoffs).toEqual([undefined]) }), diff --git a/examples/alchemy-cloudflare/test/pull-request-branches.vi.test.ts b/examples/alchemy-cloudflare/test/pull-request-branches.vi.test.ts index e216246..d36f2e6 100644 --- a/examples/alchemy-cloudflare/test/pull-request-branches.vi.test.ts +++ b/examples/alchemy-cloudflare/test/pull-request-branches.vi.test.ts @@ -91,10 +91,10 @@ describe('workBranchFor', () => { describe('openIssuePullRequest', () => { const target = { issue, branch: 'humanlayer/issue-42', base: 'main' } - const open = (existing: ReadonlyArray, body: string) => + const open = (existing: ReadonlyArray, body: string, draft?: boolean) => Effect.gen(function* () { const created = yield* Ref.make>([]) - const result = yield* openIssuePullRequest(target, { title: 'Fix the crash', body }).pipe( + const result = yield* openIssuePullRequest(target, { title: 'Fix the crash', body, draft }).pipe( Effect.provide( Layer.mock(GitHubApi, { listPullRequestsForBranch: () => Effect.succeed(existing), @@ -111,7 +111,9 @@ describe('openIssuePullRequest', () => { const { result, created } = yield* open([], 'Handles a null config.') expect(result).toEqual( - ToolResultText.make({ text: 'Opened https://github.com/humanlayer/effect-channels/pull/44' }), + ToolResultText.make({ + text: 'Opened a pull request: https://github.com/humanlayer/effect-channels/pull/44', + }), ) expect(created).toEqual([ { @@ -120,11 +122,25 @@ describe('openIssuePullRequest', () => { base: 'main', title: 'Fix the crash', body: 'Handles a null config.\n\nCloses #42', + draft: false, }, ]) }), ) + it.effect('opens a draft pull request when asked to', ({ expect }) => + Effect.gen(function* () { + const { result, created } = yield* open([], 'Handles a null config.', true) + + expect(result).toEqual( + ToolResultText.make({ + text: 'Opened a draft pull request: https://github.com/humanlayer/effect-channels/pull/44', + }), + ) + expect(created.map((input) => input.draft)).toEqual([true]) + }), + ) + it.effect('keeps a body that already closes the issue', ({ expect }) => Effect.gen(function* () { const { created } = yield* open([], 'Closes #42 by handling a null config.') diff --git a/packages/github/README.md b/packages/github/README.md index 7adee9d..16160ec 100644 --- a/packages/github/README.md +++ b/packages/github/README.md @@ -180,7 +180,7 @@ In addition to `fetchInfo()`, conversation comments, reviews, review-comment lis `fetchInfo()` includes `headRepository`, the repository the head branch lives in: a fork's for a pull request from a fork, and `null` once that repository is deleted. -Two `GitHubApi` operations work on a repository rather than one pull request: `listPullRequestsForBranch({ repository, head })` returns the open pull requests from a branch of that repository (not from forks), and `createPullRequest({ repository, head, base, title, body })` opens one. Creating one needs the App's pull requests write permission. +Two `GitHubApi` operations work on a repository rather than one pull request: `listPullRequestsForBranch({ repository, head })` returns the open pull requests from a branch of that repository (not from forks), and `createPullRequest({ repository, head, base, title, body, draft })` opens one, as a draft when `draft` is true. Creating one needs the App's pull requests write permission. Prefer `listFiles()` when an agent only needs selected changes. GitHub's wire status `removed` is exposed as `deleted`; `copied`, `changed`, and `unchanged` remain distinct statuses rather than being collapsed into `modified`. A file's `sha`, `blobUrl`, and `rawUrl` can be `null`, including for some submodule entries. GitHub limits that endpoint to 3,000 files, may omit `patch` for binary or unusually large files, and limits `listCommits()` to 250 PR commits. `fetchDiff()` loads the complete unified diff into one string. diff --git a/packages/github/src/GitHubApi.ts b/packages/github/src/GitHubApi.ts index 4d75ab3..fedb377 100644 --- a/packages/github/src/GitHubApi.ts +++ b/packages/github/src/GitHubApi.ts @@ -168,13 +168,14 @@ export const GitHubListPullRequestsForBranch = Schema.Struct({ }) export interface GitHubListPullRequestsForBranch extends Schema.Schema.Type {} -/** A pull request from `head` into `base`, both branches of `repository`. */ +/** A pull request from `head` into `base`, both branches of `repository`; a draft when `draft` is true. */ export const GitHubCreatePullRequest = Schema.Struct({ repository: GitHubRepositoryRef, head: Schema.NonEmptyString, base: Schema.NonEmptyString, title: Schema.NonEmptyString, body: Schema.String, + draft: Schema.optionalKey(Schema.Boolean), }) export interface GitHubCreatePullRequest extends Schema.Schema.Type {} diff --git a/packages/github/src/api/CreatePullRequest.ts b/packages/github/src/api/CreatePullRequest.ts index ad63146..1cf6c9e 100644 --- a/packages/github/src/api/CreatePullRequest.ts +++ b/packages/github/src/api/CreatePullRequest.ts @@ -10,6 +10,7 @@ const CreatePullRequestBody = Schema.Struct({ body: Schema.String, head: Schema.String, base: Schema.String, + draft: Schema.Boolean, }) export const createPullRequest = Effect.fn('github.api.create_pull_request')(function* ( @@ -24,7 +25,13 @@ export const createPullRequest = Effect.fn('github.api.create_pull_request')(fun schema: PullRequest, body: { schema: CreatePullRequestBody, - value: { title: input.title, body: input.body, head: input.head, base: input.base }, + value: { + title: input.title, + body: input.body, + head: input.head, + base: input.base, + draft: input.draft ?? false, + }, }, }) return pullRequestInfo({ ...input.repository, number: value.number }, value) diff --git a/packages/github/test/github-api-capabilities.vi.test.ts b/packages/github/test/github-api-capabilities.vi.test.ts index c3c5df5..5a7977c 100644 --- a/packages/github/test/github-api-capabilities.vi.test.ts +++ b/packages/github/test/github-api-capabilities.vi.test.ts @@ -633,6 +633,7 @@ describe('GitHubApiLive agent capabilities', () => { base: 'main', title: 'Fix the crash', body: 'Closes #42', + draft: true, }) return { open, created } }).pipe(Effect.provide(makeLayer(httpClient))) @@ -653,7 +654,7 @@ describe('GitHubApiLive agent capabilities', () => { method: 'POST', path: '/repos/humanlayer/channels/pulls', search: '', - body: '{"title":"Fix the crash","body":"Closes #42","head":"humanlayer/issue-42","base":"main"}', + body: '{"title":"Fix the crash","body":"Closes #42","head":"humanlayer/issue-42","base":"main","draft":true}', }, ]) }),