diff --git a/vendor/agents/docs/content/docs/pi.mdx b/vendor/agents/docs/content/docs/pi.mdx index edacb813..b4c53eac 100644 --- a/vendor/agents/docs/content/docs/pi.mdx +++ b/vendor/agents/docs/content/docs/pi.mdx @@ -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 source 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 source 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`: + + + +- 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 @@ -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. diff --git a/vendor/agents/docs/content/guides/personal-agent.mdx b/vendor/agents/docs/content/guides/personal-agent.mdx new file mode 100644 index 00000000..e612bf4d --- /dev/null +++ b/vendor/agents/docs/content/guides/personal-agent.mdx @@ -0,0 +1,198 @@ +--- +title: "Personal Agent over SMS" +description: "Give each user their own agent that they sign in to with their phone number and talk to by text message." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + +
+ + + + + + + + + + + + + + + + + + + Sign-in page + your app + Twilio + SMS and Verify + HTTP server + /sms/webhook + user + sign-in in SQLite + assistant + the user's agent + Sandbox + optional + + + text + sendCode, verify + isSignedIn + submit + onAnswer texts the reply + + + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + One per phone number + + Rivet Actor + + Agent + + Call + + Optional + + +
+ +What we'll build: + +- A sign-in where users enter their phone number, then the code they get by text. +- A `user` Actor per phone number that stores the sign-in in its SQLite database. +- An `assistant` agent per user that answers texts and can look up the weather. +- A Twilio webhook that routes each text to the sender's agent. + + + + + +```sh +npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai rivetkit hono @hono/node-server +``` + + + + + +In the Twilio Console, buy a phone number that can send SMS and create a Verify service for the sign-in codes. Then set: + +```sh +TWILIO_ACCOUNT_SID=AC... +TWILIO_AUTH_TOKEN=... +TWILIO_PHONE_NUMBER=+15550100000 +TWILIO_VERIFY_SERVICE_SID=VA... +TWILIO_WEBHOOK_URL=https:///sms/webhook +ANTHROPIC_API_KEY=sk-ant-... +``` + + + + + +Each user gets their own `assistant` agent, keyed by their phone number: + + + +- There's no step that creates an agent. The HTTP server calls `assistant.getOrCreate([phone])`, so a user's first text creates their agent and every later text reaches the same one, with the whole conversation so far. +- `onAnswer` runs for every answer the agent gives, with the agent's own context, so `c.key` is the user's phone number. It texts the answer back. It runs inside the agent's run, so an answer that finishes after a crash or restart still gets sent. + + + + + +Tools are installed on the agent's registry, next to its system prompt: + + + + + + +`get_weather` returns mock weather. Replace its body with a call to a real weather API, or add your own tools the same way. See [Custom Tools](/agents/docs/custom-tools). To let the agent run code, edit files, or browse, give it a [sandbox](/agents/docs/sandboxes) with Pi's built-in tools. + + + + + +Each phone number also gets a `user` Actor, keyed by the same number. It runs the two-step sign-in and remembers it in its SQLite database: + + + +- `sendCode` texts the user a code with Twilio Verify, which creates, expires, and rate limits it, so the Actor never stores it. +- `verify` checks the code and saves the sign-in in the `profile` table. +- `isSignedIn` tells the HTTP server whether a number has signed in. +- Keys use the E.164 format, such as `+15551234567`, because that's how Twilio sends the sender's number. + + +This sign-in is simplified. Any client can call `user` by phone number. In production, return a session after `verify`, such as a [JWT](/docs/jwt), and protect the Actor with [authentication](/docs/authentication). + + + + + + +The HTTP server has one route. Twilio sends every text to `/sms/webhook`, which checks the sender's `user` Actor and hands the text to their `assistant`: + + + +- The webhook checks Twilio's signature, which covers the URL Twilio called. Set `TWILIO_WEBHOOK_URL` to exactly the URL you give Twilio, because a proxy or tunnel can change the URL the server sees. +- It answers with TwiML. An empty `` sends nothing, because the assistant replies on its own. A number that hasn't signed in gets a message telling it to sign in. +- `submit` hands the text to the agent and returns without waiting for the answer, so the webhook responds within Twilio's 15-second limit. The agent texts its answer from `onAnswer`. +- If a text is delivered twice, the `requestId` stops the agent from answering it again. + + + + + +In the Twilio Console, open your phone number. Under **Messaging**, set **A message comes in** to **Webhook**, `https:///sms/webhook`, `HTTP POST`. This is the same URL as `TWILIO_WEBHOOK_URL`. + + +If you haven't deployed yet, test locally with ngrok. Run the server with `npx tsx server.ts`, run `ngrok http 3000` in another terminal, and use the ngrok URL in place of `https://` for both the webhook and `TWILIO_WEBHOOK_URL`. + + +Sign in with your phone number: + + + +Then text the Twilio number "What's the weather in Tokyo?" + + + + + +## Next steps + +- [Sandboxes](/agents/docs/sandboxes): give the agent a computer to run code and browse. +- [Custom Tools](/agents/docs/custom-tools): add tools for calendars, notes, or your own APIs. +- [Schedules](/agents/docs/schedules): text the user a forecast every morning without being asked. +- [Human in the Loop](/agents/docs/human-in-the-loop): ask the user before the agent acts. +- [Slack](/agents/docs/connectors/slack), [Discord](/agents/docs/connectors/discord), [GitHub](/agents/docs/connectors/github), and [Linear](/agents/docs/connectors/linear): reach agents from other places. +- [User Subscriptions](/agents/docs/user-subscriptions): run each user's agent on their own Claude or ChatGPT plan. + +## Related docs + +- [Design Patterns](/agents/docs/design-patterns): why each user gets their own agent. +- [Custom Tools](/agents/docs/custom-tools): how the weather tool works. +- [Slack](/agents/docs/connectors/slack): the same webhook pattern for Slack. diff --git a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx index 004dcc34..ea38bda6 100644 --- a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx +++ b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx @@ -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"; - +
diff --git a/vendor/agents/docs/sidebar.json b/vendor/agents/docs/sidebar.json index e980f209..5b5b7697 100644 --- a/vendor/agents/docs/sidebar.json +++ b/vendor/agents/docs/sidebar.json @@ -12,6 +12,12 @@ "title": "Quickstart", "href": "/agents/docs/quickstart", "icon": "faForwardFast" + }, + { + "title": "Guides", + "icon": "faBookOpen", + "collapsible": true, + "mirror": "guides" } ] }, @@ -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", diff --git a/vendor/agents/examples/docs/package.json b/vendor/agents/examples/docs/package.json index 644f0c84..227f973c 100644 --- a/vendor/agents/examples/docs/package.json +++ b/vendor/agents/examples/docs/package.json @@ -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", diff --git a/vendor/agents/examples/docs/personal-agent/assistant.ts b/vendor/agents/examples/docs/personal-agent/assistant.ts new file mode 100644 index 00000000..1e4a6a0e --- /dev/null +++ b/vendor/agents/examples/docs/personal-agent/assistant.ts @@ -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}.`); +} diff --git a/vendor/agents/examples/docs/personal-agent/client.ts b/vendor/agents/examples/docs/personal-agent/client.ts new file mode 100644 index 00000000..d7637bba --- /dev/null +++ b/vendor/agents/examples/docs/personal-agent/client.ts @@ -0,0 +1,20 @@ +import { createInterface } from "node:readline/promises"; +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient(); +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(); diff --git a/vendor/agents/examples/docs/personal-agent/server.ts b/vendor/agents/examples/docs/personal-agent/server.ts new file mode 100644 index 00000000..0c51d083 --- /dev/null +++ b/vendor/agents/examples/docs/personal-agent/server.ts @@ -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(); + +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("Sign in with this number to talk to your assistant."); + } + + // 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, 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(`${message}`, { + headers: { "content-type": "text/xml" }, + }); +} diff --git a/vendor/agents/examples/docs/personal-agent/user.ts b/vendor/agents/examples/docs/personal-agent/user.ts new file mode 100644 index 00000000..11d98b1a --- /dev/null +++ b/vendor/agents/examples/docs/personal-agent/user.ts @@ -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 => { + 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) { + 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 }; +} diff --git a/vendor/agents/examples/docs/personal-agent/weather.ts b/vendor/agents/examples/docs/personal-agent/weather.ts new file mode 100644 index 00000000..bcc0b6e0 --- /dev/null +++ b/vendor/agents/examples/docs/personal-agent/weather.ts @@ -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 }; + }, +});