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
18 changes: 16 additions & 2 deletions vendor/agents/docs/content/docs/pi.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,27 @@ The `prompt` action sends input, waits, and returns `{ status: "done", text }` o
| To | Call |
| --- | --- |
| Prompt and wait | `prompt(text, { requestId })` |
| Start a run without waiting | `conversation.submit(id, { type: "input", content, requestId })` |
| Start a run without waiting | `submit(text, { requestId })` |
| Steer a run | `prompt(text, { whenBusy: "steer" })` |
| Cancel a run | `conversation.abort(id)` |
| Switch models | `conversation.configure(id, { model: { provider, modelId } })` |
| Get the root conversation | `harness.root()` |
| Start a conversation | `harness.createConversation({ ownership: { kind: "ownerless" } })` |

Actions wait up to ten minutes, and a timed-out wait leaves the run going. A prompt sent during a run queues as a follow-up. See the <a href="https://github.com/rivet-dev/agents/blob/main/packages/pi/src/actions.ts" target="_blank" rel="noopener noreferrer">source</a> for every action.
Actions wait up to ten minutes, and a timed-out wait leaves the run going. A prompt sent during a run queues as a follow-up. `submit` takes the same options as `prompt` and returns `{ submissionId }` at once. Pair it with `onAnswer` to deliver the answer. See the <a href="https://github.com/rivet-dev/agents/blob/main/packages/pi/src/actions.ts" target="_blank" rel="noopener noreferrer">source</a> for every action.

## Deliver answers

`onAnswer` runs in the agent Actor for every final answer, with the Actor's context. Use it to send answers somewhere, such as a chat app or a text message, without a client waiting on `prompt`:

<CodeSnippet file="examples/docs/personal-agent/assistant.ts" title="assistant.ts" region="answer" />

- It gets `{ conversationId, text, message }`. Responses that only call tools aren't final answers, so they don't call it.
- It runs inside the run, so an answer that finishes after a crash or restart is still delivered. If the Actor stops while `onAnswer` is running, it can run again for the same answer.
- A throw goes to `onReport` and leaves the answer in the conversation.
- It runs in conversations that use the default extensions. A conversation that selects its extensions by name doesn't get it.

See [Personal Agent over SMS](/guides/personal-agent) for a complete example.

## Stream a conversation

Expand Down Expand Up @@ -211,6 +224,7 @@ The `pi()` function accepts every [`actor()`](/actors/docs/actor-configuration)
| `sandbox` | Where `CodingTools` run. Without one, files live in the Actor's database. See [Sandboxes](/agents/docs/sandboxes). |
| `documents` | Documents clients may read. |
| `settings` | Pi Durable settings, such as `retry` and `compaction`. |
| `onAnswer` | Runs for every final answer. See [Deliver answers](#deliver-answers). |

Other options, such as `thinkingLevel` and `env`, pass through to Pi Durable. By default, `actionTimeout` is ten minutes and `sleepGracePeriod` is 15 minutes. A `ConversationBusy` error reaches clients as a `UserError`, and other Pi Durable errors as internal errors.

Expand Down
198 changes: 198 additions & 0 deletions vendor/agents/docs/content/guides/personal-agent.mdx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: "Let users connect their ChatGPT plan so your agents run on it, wit

import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro";

<ExampleLinkBar href="https://github.com/rivet-dev/agents/tree/main/examples/docs/sign-in-with-chatgpt" label="Prefer to read code? See the full example on GitHub." />
<ExampleLinkBar href="https://github.com/rivet-dev/agents/tree/main/examples/docs/sign-in-with-chatgpt" />

<div style="overflow-x:auto">
<svg viewBox="0 0 700 430" role="img" aria-label="The user calls start on the chatgptLogin Actor, which returns a sign-in URL. The user signs in at OpenAI and chooses Continue. OpenAI redirects the browser to a 127.0.0.1 URL. The user passes that URL to finish. The chatgptLogin Actor exchanges the code with OpenAI for tokens and saves the login to the user's credentials Actor." style="width:100%;min-width:600px;max-width:700px;height:auto;display:block;margin:2.5rem auto;font-family:system-ui,sans-serif">
Expand Down
11 changes: 11 additions & 0 deletions vendor/agents/docs/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"title": "Quickstart",
"href": "/agents/docs/quickstart",
"icon": "faForwardFast"
},
{
"title": "Guides",
"icon": "faBookOpen",
"collapsible": true,
"mirror": "guides"
}
]
},
Expand Down Expand Up @@ -182,6 +188,11 @@
{
"title": "Agents",
"pages": [
{
"title": "Personal Agent over SMS",
"href": "/guides/personal-agent",
"icon": "faCommentSms"
},
{
"title": "Sign in with ChatGPT",
"href": "/guides/sign-in-with-chatgpt",
Expand Down
1 change: 1 addition & 0 deletions vendor/agents/examples/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@earendil-works/pi-ai": "^1.0.4",
"@earendil-works/pi-coding-agent": "^1.0.4",
"@earendil-works/pi-durable": "^1.0.4",
"@hono/node-server": "^2.1.5",
"@linear/sdk": "^97.0.0",
"@octokit/rest": "^22.0.1",
"@octokit/webhooks-methods": "^6.0.0",
Expand Down
43 changes: 43 additions & 0 deletions vendor/agents/examples/docs/personal-agent/assistant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { getWeather } from "./weather";

// docs:start tools
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "personal-assistant",
tools: [getWeather],
sections: [
section(
"role",
() => "You are a personal assistant that talks over SMS. Reply in plain text, without Markdown, in a few short sentences.",
),
],
}),
);
// docs:end tools

// One assistant per user, keyed by their phone number.
export const assistant = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
// docs:start answer
// Texts every answer to the user whose agent this is.
onAnswer: async (c, answer) => {
const [phone] = c.key;
await sendSms(phone, answer.text ?? "Sorry, I couldn't answer that.");
},
// docs:end answer
});

async function sendSms(to: string, body: string) {
const sid = process.env.TWILIO_ACCOUNT_SID;
const auth = btoa(`${sid}:${process.env.TWILIO_AUTH_TOKEN}`);
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, {
method: "POST",
headers: { authorization: `Basic ${auth}` },
body: new URLSearchParams({ To: to, From: process.env.TWILIO_PHONE_NUMBER ?? "", Body: body }),
});
if (!response.ok) throw new Error(`Twilio returned ${response.status}.`);
}
20 changes: 20 additions & 0 deletions vendor/agents/examples/docs/personal-agent/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createInterface } from "node:readline/promises";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";

const client = createClient<typeof registry>();
const input = createInterface({ input: process.stdin, output: process.stdout });

// 1. Enter your number and send a code.
const phone = await input.question("Phone number, such as +15551234567: ");
const user = client.user.getOrCreate([phone]);
await user.sendCode();

// 2. Enter the code you got.
const code = await input.question("Code: ");
if (await user.verify(code)) {
console.log(`Signed in. Text ${process.env.TWILIO_PHONE_NUMBER} to talk to your assistant.`);
} else {
console.log("That code is wrong or expired.");
}
input.close();
52 changes: 52 additions & 0 deletions vendor/agents/examples/docs/personal-agent/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { setup } from "rivetkit";
import { createClient } from "rivetkit/client";
import { assistant } from "./assistant";
import { user } from "./user";

export const registry = setup({ use: { user, assistant } });
const client = createClient<typeof registry>();

registry.start();

const app = new Hono();

// docs:start webhook
// Twilio calls this for every text to your number.
app.post("/sms/webhook", async (c) => {
const params = Object.fromEntries(new URLSearchParams(await c.req.text()));
if (!verifyTwilioSignature(params, c.req.header("x-twilio-signature") ?? "")) {
return c.text("invalid signature", 401);
}

// Ask the sender's user Actor whether they signed in.
const phone = params.From;
if (!(await client.user.getOrCreate([phone]).isSignedIn())) {
return twiml("<Message>Sign in with this number to talk to your assistant.</Message>");
}

// Hand the text to the sender's assistant without waiting for the answer. Their first text creates it.
// The message id stops a duplicate delivery from running the agent twice.
await client.assistant.getOrCreate([phone]).submit(params.Body, { requestId: params.MessageSid });
// An empty response sends nothing. The assistant texts its answer from onAnswer.
return twiml("");
});
// docs:end webhook

serve({ fetch: app.fetch, port: 3000 });

// Twilio signs the webhook URL followed by each POST parameter's name and value, sorted by name.
function verifyTwilioSignature(params: Record<string, string>, signature: string): boolean {
const url = process.env.TWILIO_WEBHOOK_URL ?? "";
const payload = url + Object.keys(params).sort().map((key) => key + params[key]).join("");
const expected = createHmac("sha1", process.env.TWILIO_AUTH_TOKEN ?? "").update(payload).digest("base64");
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

function twiml(message: string) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><Response>${message}</Response>`, {
headers: { "content-type": "text/xml" },
});
}
58 changes: 58 additions & 0 deletions vendor/agents/examples/docs/personal-agent/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { actor } from "rivetkit";
import { db } from "rivetkit/db";

// One user per phone number. The key is the number in E.164 format, such as +15551234567.
export const user = actor({
// docs:start schema
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
verified_at INTEGER NOT NULL
)
`);
},
}),
// docs:end schema
actions: {
// docs:start sign-in
// Step 1: text the user a code. Twilio Verify creates, expires, and rate limits it.
sendCode: async (c) => {
const [phone] = c.key;
await twilioVerify("Verifications", { To: phone, Channel: "sms" });
},
// Step 2: check the code and remember the sign-in.
verify: async (c, code: string) => {
const [phone] = c.key;
const check = await twilioVerify("VerificationCheck", { To: phone, Code: code });
if (check?.status !== "approved") return false;
await c.db.execute(
"INSERT INTO profile (id, verified_at) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET verified_at = excluded.verified_at",
Date.now(),
);
return true;
},
// docs:end sign-in
// docs:start signed-in
isSignedIn: async (c): Promise<boolean> => {
const rows = await c.db.execute("SELECT verified_at FROM profile");
return rows.length > 0;
},
// docs:end signed-in
},
});

async function twilioVerify(path: string, params: Record<string, string>) {
const service = process.env.TWILIO_VERIFY_SERVICE_SID;
const auth = btoa(`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`);
const response = await fetch(`https://verify.twilio.com/v2/Services/${service}/${path}`, {
method: "POST",
headers: { authorization: `Basic ${auth}` },
body: new URLSearchParams(params),
});
// Verify answers 404 when there's no pending code for the number, such as after it expired.
if (response.status === 404) return undefined;
if (!response.ok) throw new Error(`Twilio Verify returned ${response.status}.`);
return (await response.json()) as { status: string };
}
16 changes: 16 additions & 0 deletions vendor/agents/examples/docs/personal-agent/weather.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";

export const getWeather = defineTool({
name: "get_weather",
description: "Get the current weather in a city.",
parameters: Type.Object({ city: Type.String() }),
// A lookup changes nothing, so running it twice is harmless.
replay: "safe",
execute: async ({ city }) => {
// A mock. Call a real weather API here.
const weather = { city, temperatureC: 18, conditions: "sunny" };
const text = `${city}: ${weather.temperatureC}°C and ${weather.conditions}.`;
return { content: [{ type: "text", text }], details: weather };
},
});
Loading