Skip to content

Commit 798798f

Browse files
NathanFlurryrivet-docs-sync[bot]
andauthored
docs(agents): sync from rivet-dev/agents@67d0ef9 (#154)
Co-authored-by: rivet-docs-sync[bot] <docs-sync@rivet.dev>
1 parent 98e8692 commit 798798f

10 files changed

Lines changed: 416 additions & 3 deletions

File tree

‎vendor/agents/docs/content/docs/pi.mdx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,27 @@ The `prompt` action sends input, waits, and returns `{ status: "done", text }` o
139139
| To | Call |
140140
| --- | --- |
141141
| Prompt and wait | `prompt(text, { requestId })` |
142-
| Start a run without waiting | `conversation.submit(id, { type: "input", content, requestId })` |
142+
| Start a run without waiting | `submit(text, { requestId })` |
143143
| Steer a run | `prompt(text, { whenBusy: "steer" })` |
144144
| Cancel a run | `conversation.abort(id)` |
145145
| Switch models | `conversation.configure(id, { model: { provider, modelId } })` |
146146
| Get the root conversation | `harness.root()` |
147147
| Start a conversation | `harness.createConversation({ ownership: { kind: "ownerless" } })` |
148148

149-
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.
149+
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.
150+
151+
## Deliver answers
152+
153+
`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`:
154+
155+
<CodeSnippet file="examples/docs/personal-agent/assistant.ts" title="assistant.ts" region="answer" />
156+
157+
- It gets `{ conversationId, text, message }`. Responses that only call tools aren't final answers, so they don't call it.
158+
- 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.
159+
- A throw goes to `onReport` and leaves the answer in the conversation.
160+
- It runs in conversations that use the default extensions. A conversation that selects its extensions by name doesn't get it.
161+
162+
See [Personal Agent over SMS](/guides/personal-agent) for a complete example.
150163

151164
## Stream a conversation
152165

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

215229
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.
216230

‎vendor/agents/docs/content/guides/personal-agent.mdx‎

Lines changed: 198 additions & 0 deletions
Large diffs are not rendered by default.

‎vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: "Let users connect their ChatGPT plan so your agents run on it, wit
55

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

8-
<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." />
8+
<ExampleLinkBar href="https://github.com/rivet-dev/agents/tree/main/examples/docs/sign-in-with-chatgpt" />
99

1010
<div style="overflow-x:auto">
1111
<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">

‎vendor/agents/docs/sidebar.json‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
"title": "Quickstart",
1313
"href": "/agents/docs/quickstart",
1414
"icon": "faForwardFast"
15+
},
16+
{
17+
"title": "Guides",
18+
"icon": "faBookOpen",
19+
"collapsible": true,
20+
"mirror": "guides"
1521
}
1622
]
1723
},
@@ -182,6 +188,11 @@
182188
{
183189
"title": "Agents",
184190
"pages": [
191+
{
192+
"title": "Personal Agent over SMS",
193+
"href": "/guides/personal-agent",
194+
"icon": "faCommentSms"
195+
},
185196
{
186197
"title": "Sign in with ChatGPT",
187198
"href": "/guides/sign-in-with-chatgpt",

‎vendor/agents/examples/docs/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@earendil-works/pi-ai": "^1.0.4",
1212
"@earendil-works/pi-coding-agent": "^1.0.4",
1313
"@earendil-works/pi-durable": "^1.0.4",
14+
"@hono/node-server": "^2.1.5",
1415
"@linear/sdk": "^97.0.0",
1516
"@octokit/rest": "^22.0.1",
1617
"@octokit/webhooks-methods": "^6.0.0",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
2+
import { pi } from "@rivet-dev/pi";
3+
import { getWeather } from "./weather";
4+
5+
// docs:start tools
6+
const extensions = createRegistry();
7+
extensions.install(
8+
defineExtension({
9+
name: "personal-assistant",
10+
tools: [getWeather],
11+
sections: [
12+
section(
13+
"role",
14+
() => "You are a personal assistant that talks over SMS. Reply in plain text, without Markdown, in a few short sentences.",
15+
),
16+
],
17+
}),
18+
);
19+
// docs:end tools
20+
21+
// One assistant per user, keyed by their phone number.
22+
export const assistant = pi({
23+
model: "anthropic/claude-opus-5-5",
24+
registry: extensions,
25+
// docs:start answer
26+
// Texts every answer to the user whose agent this is.
27+
onAnswer: async (c, answer) => {
28+
const [phone] = c.key;
29+
await sendSms(phone, answer.text ?? "Sorry, I couldn't answer that.");
30+
},
31+
// docs:end answer
32+
});
33+
34+
async function sendSms(to: string, body: string) {
35+
const sid = process.env.TWILIO_ACCOUNT_SID;
36+
const auth = btoa(`${sid}:${process.env.TWILIO_AUTH_TOKEN}`);
37+
const response = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, {
38+
method: "POST",
39+
headers: { authorization: `Basic ${auth}` },
40+
body: new URLSearchParams({ To: to, From: process.env.TWILIO_PHONE_NUMBER ?? "", Body: body }),
41+
});
42+
if (!response.ok) throw new Error(`Twilio returned ${response.status}.`);
43+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createInterface } from "node:readline/promises";
2+
import { createClient } from "rivetkit/client";
3+
import type { registry } from "./server";
4+
5+
const client = createClient<typeof registry>();
6+
const input = createInterface({ input: process.stdin, output: process.stdout });
7+
8+
// 1. Enter your number and send a code.
9+
const phone = await input.question("Phone number, such as +15551234567: ");
10+
const user = client.user.getOrCreate([phone]);
11+
await user.sendCode();
12+
13+
// 2. Enter the code you got.
14+
const code = await input.question("Code: ");
15+
if (await user.verify(code)) {
16+
console.log(`Signed in. Text ${process.env.TWILIO_PHONE_NUMBER} to talk to your assistant.`);
17+
} else {
18+
console.log("That code is wrong or expired.");
19+
}
20+
input.close();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createHmac, timingSafeEqual } from "node:crypto";
2+
import { serve } from "@hono/node-server";
3+
import { Hono } from "hono";
4+
import { setup } from "rivetkit";
5+
import { createClient } from "rivetkit/client";
6+
import { assistant } from "./assistant";
7+
import { user } from "./user";
8+
9+
export const registry = setup({ use: { user, assistant } });
10+
const client = createClient<typeof registry>();
11+
12+
registry.start();
13+
14+
const app = new Hono();
15+
16+
// docs:start webhook
17+
// Twilio calls this for every text to your number.
18+
app.post("/sms/webhook", async (c) => {
19+
const params = Object.fromEntries(new URLSearchParams(await c.req.text()));
20+
if (!verifyTwilioSignature(params, c.req.header("x-twilio-signature") ?? "")) {
21+
return c.text("invalid signature", 401);
22+
}
23+
24+
// Ask the sender's user Actor whether they signed in.
25+
const phone = params.From;
26+
if (!(await client.user.getOrCreate([phone]).isSignedIn())) {
27+
return twiml("<Message>Sign in with this number to talk to your assistant.</Message>");
28+
}
29+
30+
// Hand the text to the sender's assistant without waiting for the answer. Their first text creates it.
31+
// The message id stops a duplicate delivery from running the agent twice.
32+
await client.assistant.getOrCreate([phone]).submit(params.Body, { requestId: params.MessageSid });
33+
// An empty response sends nothing. The assistant texts its answer from onAnswer.
34+
return twiml("");
35+
});
36+
// docs:end webhook
37+
38+
serve({ fetch: app.fetch, port: 3000 });
39+
40+
// Twilio signs the webhook URL followed by each POST parameter's name and value, sorted by name.
41+
function verifyTwilioSignature(params: Record<string, string>, signature: string): boolean {
42+
const url = process.env.TWILIO_WEBHOOK_URL ?? "";
43+
const payload = url + Object.keys(params).sort().map((key) => key + params[key]).join("");
44+
const expected = createHmac("sha1", process.env.TWILIO_AUTH_TOKEN ?? "").update(payload).digest("base64");
45+
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
46+
}
47+
48+
function twiml(message: string) {
49+
return new Response(`<?xml version="1.0" encoding="UTF-8"?><Response>${message}</Response>`, {
50+
headers: { "content-type": "text/xml" },
51+
});
52+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { actor } from "rivetkit";
2+
import { db } from "rivetkit/db";
3+
4+
// One user per phone number. The key is the number in E.164 format, such as +15551234567.
5+
export const user = actor({
6+
// docs:start schema
7+
db: db({
8+
onMigrate: async (db) => {
9+
await db.execute(`
10+
CREATE TABLE IF NOT EXISTS profile (
11+
id INTEGER PRIMARY KEY CHECK (id = 1),
12+
verified_at INTEGER NOT NULL
13+
)
14+
`);
15+
},
16+
}),
17+
// docs:end schema
18+
actions: {
19+
// docs:start sign-in
20+
// Step 1: text the user a code. Twilio Verify creates, expires, and rate limits it.
21+
sendCode: async (c) => {
22+
const [phone] = c.key;
23+
await twilioVerify("Verifications", { To: phone, Channel: "sms" });
24+
},
25+
// Step 2: check the code and remember the sign-in.
26+
verify: async (c, code: string) => {
27+
const [phone] = c.key;
28+
const check = await twilioVerify("VerificationCheck", { To: phone, Code: code });
29+
if (check?.status !== "approved") return false;
30+
await c.db.execute(
31+
"INSERT INTO profile (id, verified_at) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET verified_at = excluded.verified_at",
32+
Date.now(),
33+
);
34+
return true;
35+
},
36+
// docs:end sign-in
37+
// docs:start signed-in
38+
isSignedIn: async (c): Promise<boolean> => {
39+
const rows = await c.db.execute("SELECT verified_at FROM profile");
40+
return rows.length > 0;
41+
},
42+
// docs:end signed-in
43+
},
44+
});
45+
46+
async function twilioVerify(path: string, params: Record<string, string>) {
47+
const service = process.env.TWILIO_VERIFY_SERVICE_SID;
48+
const auth = btoa(`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`);
49+
const response = await fetch(`https://verify.twilio.com/v2/Services/${service}/${path}`, {
50+
method: "POST",
51+
headers: { authorization: `Basic ${auth}` },
52+
body: new URLSearchParams(params),
53+
});
54+
// Verify answers 404 when there's no pending code for the number, such as after it expired.
55+
if (response.status === 404) return undefined;
56+
if (!response.ok) throw new Error(`Twilio Verify returned ${response.status}.`);
57+
return (await response.json()) as { status: string };
58+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Type } from "@earendil-works/pi-ai";
2+
import { defineTool } from "@earendil-works/pi-durable";
3+
4+
export const getWeather = defineTool({
5+
name: "get_weather",
6+
description: "Get the current weather in a city.",
7+
parameters: Type.Object({ city: Type.String() }),
8+
// A lookup changes nothing, so running it twice is harmless.
9+
replay: "safe",
10+
execute: async ({ city }) => {
11+
// A mock. Call a real weather API here.
12+
const weather = { city, temperatureC: 18, conditions: "sunny" };
13+
const text = `${city}: ${weather.temperatureC}°C and ${weather.conditions}.`;
14+
return { content: [{ type: "text", text }], details: weather };
15+
},
16+
});

0 commit comments

Comments
 (0)