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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 7 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'
Expand All @@ -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 |
| ---------- | --------------------------------------------------------------- | ------------------------- |
Expand Down
458 changes: 156 additions & 302 deletions examples/alchemy-cloudflare/README.md

Large diffs are not rendered by default.

300 changes: 300 additions & 0 deletions examples/alchemy-cloudflare/SETUP.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion examples/alchemy-cloudflare/src/AgentSessionDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
6 changes: 3 additions & 3 deletions examples/alchemy-cloudflare/src/DeliveryTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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),
})
Expand Down
16 changes: 8 additions & 8 deletions examples/alchemy-cloudflare/src/DiscussionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -109,25 +109,25 @@ const render = (heading: string, sections: ReadonlyArray<readonly [string, Reado

/**
* Read the discussion and show the agent what it has not seen: everything when `seen` is empty, otherwise
* what is new. The bot's own comments and the comment that mentioned it are left out of the text but count as
* what is new. The bot's own comments and the request's comments are left out of the text but count as
* seen. The text is empty when nothing is new.
*/
export const readDiscussionContext = Effect.fn('agent_session.read_discussion')(function* (input: {
readonly discussion: GitHubIssue | GitHubPullRequest
readonly seen: Option.Option<DiscussionSeen>
readonly botUserId: number
readonly mentionedIn: MentionedIn | undefined
readonly requestComments: ReadonlyArray<RequestComment>
}) {
const { discussion, botUserId, mentionedIn } = input
const { discussion, botUserId, requestComments } = input
const seen = Option.getOrElse(input.seen, (): DiscussionSeen => ({
comment: 0,
review: 0,
reviewComment: 0,
}))
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))
Expand Down
15 changes: 11 additions & 4 deletions examples/alchemy-cloudflare/src/GitHubTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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,
Expand Down
100 changes: 68 additions & 32 deletions examples/alchemy-cloudflare/src/GithubBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,21 @@
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).'
Expand Down Expand Up @@ -100,44 +102,78 @@
/** Lines of text, without the missing ones, as one string. */
const joinText = (lines: ReadonlyArray<string | null>) => 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 [
`<system-information>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\`\`\`</system-information>`,
'',
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<RequestItem> =>
Arr.dedupeWith(
[event.trigger, ...event.events].flatMap((item): ReadonlyArray<RequestItem> => {
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,

Check warning on line 147 in examples/alchemy-cloudflare/src/GithubBot.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-underscore-dangle)

examples/alchemy-cloudflare/src/GithubBot.ts:147:42: Unexpected dangling '_' in '`_tag`'.

Check warning on line 147 in examples/alchemy-cloudflare/src/GithubBot.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-underscore-dangle)

examples/alchemy-cloudflare/src/GithubBot.ts:147:23: Unexpected dangling '_' in '`_tag`'.
)

/** 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,
`<system-information>These comments were posted together, and they are the request. ${guide}</system-information>`,
items.map(({ text }) => text).join('\n\n---\n\n'),
])
}

/**
* An authorized mention subscribes the discussion, so later comments and checks reach this mailbox, and
Expand Down Expand Up @@ -202,9 +238,9 @@
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,
}),
Expand All @@ -215,7 +251,7 @@
}).pipe(
Effect.annotateLogs({
...githubRepositoryLogAnnotations(discussion.ref),
'github.discussion_kind': discussion._tag,

Check warning on line 254 in examples/alchemy-cloudflare/src/GithubBot.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-underscore-dangle)

examples/alchemy-cloudflare/src/GithubBot.ts:254:42: Unexpected dangling '_' in '`_tag`'.
'github.discussion_number': discussion.ref.number,
'github.event_id': event.trigger.eventId,
'github.actor': event.trigger.actor.login,
Expand Down
10 changes: 5 additions & 5 deletions examples/alchemy-cloudflare/test/discussion-context.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<RequestComment>
}) => {
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)
}
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading