Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/github-turn-output-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Allow GitHub webhook handlers and cross-channel `receive(...)` calls to request turn-scoped structured output with Standard Schema or raw JSON Schema. GitHub webhook turns and scheduled retries can now share one schema-validated `result.completed` contract while keeping sessions in conversation mode.
35 changes: 31 additions & 4 deletions docs/channels/github.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,55 @@ Inbound hooks return `{ auth }` to dispatch, or `null` to ignore. Use `defaultGi

```ts
import { defaultGitHubAuth, githubChannel } from "eve/channels/github";
import { z } from "zod";

const assessmentSchema = z.object({
summary: z.string(),
priority: z.enum(["low", "medium", "high"]),
});

export default githubChannel({
botName: "my-agent",
// Replaces the @mention gate. ctx.conversation.kind is "issue", "pull_request", or "review_thread".
onComment: (ctx, comment) => ({ auth: defaultGitHubAuth(ctx) }),
// Opt in; no default dispatch on these events.
onIssue: (ctx, issue) => (issue.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null),
onPullRequest: (ctx, pr) => (pr.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null),
onIssue: (ctx, issue) =>
issue.action === "opened"
? { auth: defaultGitHubAuth(ctx), outputSchema: assessmentSchema }
: null,
onPullRequest: (ctx, pr) =>
pr.action === "opened"
? { auth: defaultGitHubAuth(ctx), outputSchema: assessmentSchema }
: null,
});
```

Add `outputSchema` when a delivery needs a schema-validated result. It accepts a Standard Schema implementation such as Zod or a raw JSON Schema object and applies only to that turn. A successful structured turn emits the finalized value as `result.completed`; the durable session stays in conversation mode, and later turns do not inherit the schema.

Consume `result.completed` in an [agent hook](../guides/hooks), where `ctx.session.auth.current` provides the trusted caller for validation and side effects. Keep those side effects idempotent and handle their failures inside the hook. `message.completed` is not the structured-result signal, and the GitHub channel does not need a separate result callback. See [Output Schema](../guides/client/output-schema) for the event lifecycle and failure behavior.

### Delivery

When a turn starts, the channel adds an `eyes` reaction to the triggering comment (turn this off with `progress: { reactions: false }`). The reply comes back as a comment, on the timeline or in the review thread, and splits across multiple comments when it runs long. If the turn fails, you get a short error comment carrying an error id.

### Human-in-the-loop (HITL)

GitHub comments have no interactive button or card affordance. A human-in-the-loop (HITL) `input.requested` event is posted as a comment prompt, and the user's reply comment maps back to the pending input request. Declare an `events["input.requested"]` handler to customize the prompt.
GitHub comments have no interactive button or card affordance. A human-in-the-loop (HITL) `input.requested` event is posted as a comment prompt, and the user's reply comment maps back to the pending input request. Declare an `events["input.requested"]` handler to customize the prompt. The reply is a new turn, so its inbound hook must return `outputSchema` again when that response turn also needs structured output.

### Proactive sessions

Start a session without an inbound mention through `receive(github, { message, target, auth })` from a schedule `run` handler, or `args.receive(github, ...)` from another channel. The target requires `owner`, `repo`, and exactly one of `issueNumber` or `pullRequestNumber`.
Start a session without an inbound mention through `receive(github, { message, target, auth, outputSchema })` from a schedule `run` handler, or `args.receive(github, ...)` from another channel. The target requires `owner`, `repo`, and exactly one of `issueNumber` or `pullRequestNumber`.

```ts
await receive(github, {
message: "Assess this issue.",
target: { owner: "vercel", repo: "eve", issueNumber: 214 },
auth: appAuth,
outputSchema: assessmentSchema,
});
```

The `outputSchema` contract is identical to an inbound hook, so an initial webhook delivery and a scheduled retry can request the same structured result without changing session identity or mode.

### Attachments

Expand Down
34 changes: 33 additions & 1 deletion docs/schedules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,44 @@ export default defineSchedule({
});
```

- `receive(channel, { message, target, auth })`: starts a session on another channel. Same contract as a route handler's `args.receive`.
- `receive(channel, { message, target, auth, outputSchema? })`: starts a session on another channel. Same contract as a route handler's `args.receive`; `outputSchema` accepts Standard Schema or raw JSON Schema for this turn.
- `waitUntil(promise)`: extends the cron task's lifetime so the parked session and any in-flight fetches settle before the task ends. Wrap the `receive` call in it.
- `appAuth`: the app principal (`{ authenticator: "app", principalId: "eve:app", principalType: "runtime" }`). Pass it as `receive(..., { auth: appAuth })` for work the agent does on its own behalf.

A handler-form session runs on the same durable runtime engine as any other session, so it can park (durably suspend), for instance when the channel handoff is waiting for a Slack reply. Only markdown task mode is barred from waiting.

### Structured handoffs

Pass `outputSchema` when a scheduled handoff needs a structured result, including a retry of an earlier webhook delivery:

```ts title="agent/schedules/retry-assessment.ts"
import { defineSchedule } from "eve/schedules";
import { z } from "zod";

import github from "../channels/github.js";

const assessmentSchema = z.object({
summary: z.string(),
priority: z.enum(["low", "medium", "high"]),
});

export default defineSchedule({
cron: "*/5 * * * *",
run({ receive, waitUntil, appAuth }) {
waitUntil(
receive(github, {
message: "Assess the claimed issue delivery.",
target: { owner: "vercel", repo: "eve", issueNumber: 214 },
auth: appAuth,
outputSchema: assessmentSchema,
}),
);
},
});
```

This remains a durable conversation-mode session. The schema applies only to the delivered turn, and successful structured completion emits `result.completed` for an [agent hook](./guides/hooks) to consume.

## Trigger a schedule while iterating

The dev server mounts a one-shot dispatch route that fires a schedule by name, out of band, exactly once. Since `eve dev` never runs schedules on their cron cadence, this is how you trigger one without waiting for the next production tick.
Expand Down
19 changes: 17 additions & 2 deletions e2e/fixtures/agent-channels/agent/channels/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export default defineChannel({
const body = (await req.json().catch(() => ({}))) as {
message?: string;
sessionRef?: string;
structured?: boolean;
};
const session = await args.receive(target, {
const options = {
message: body.message ?? "Reply with the single word: hello.",
target: { sessionRef: body.sessionRef ?? crypto.randomUUID() },
auth: {
Expand All @@ -23,7 +24,21 @@ export default defineChannel({
principalId: "smoke-test",
principalType: "service",
},
});
} as const;
const session = body.structured
? await args.receive(target, {
...options,
outputSchema: {
additionalProperties: false,
properties: {
count: { type: "integer" },
title: { type: "string" },
},
required: ["title", "count"],
type: "object",
},
})
: await args.receive(target, options);
return Response.json({ ok: true, sessionId: session.id });
}),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { defineEval } from "eve/evals";

import { postChannel } from "./shared.js";

/** Structured-output smoke for the cross-channel `args.receive` handoff. */
export default defineEval({
description: "Custom channel smoke: structured cross-channel receive.",

async test(t) {
const payload = await postChannel<{ ok: boolean; sessionId?: string }>(t.target, "/webhook", {
message: "Return a structured summary of this handoff.",
structured: true,
});
if (payload.ok !== true || typeof payload.sessionId !== "string") {
throw new Error(`Unexpected webhook response: ${JSON.stringify(payload)}`);
}

const session = await t.target.attachSession(payload.sessionId);
const results = session.events.filter((event) => event.type === "result.completed");
if (results.length !== 1) {
throw new Error(`Expected one result.completed event, received ${results.length}.`);
}

const result = results[0]?.data.result;
if (
!isRecord(result) ||
typeof result.title !== "string" ||
typeof result.count !== "number" ||
!Number.isInteger(result.count)
) {
throw new Error(`Unexpected structured result: ${JSON.stringify(result)}`);
}

t.didNotFail();
t.completed();
},
});

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading
Loading