diff --git a/vendor/agents/docs/content/docs/custom-tools.mdx b/vendor/agents/docs/content/docs/custom-tools.mdx index 33683ac0..67b56872 100644 --- a/vendor/agents/docs/content/docs/custom-tools.mdx +++ b/vendor/agents/docs/content/docs/custom-tools.mdx @@ -41,7 +41,7 @@ npm add @earendil-works/pi-ai -Both `defineTool` and `defineExtension` come from `@earendil-works/pi-durable`. A tool is installed through an extension, and by default every conversation uses every installed extension. +Both `defineTool` and `defineExtension` come from `@earendil-works/pi-durable`. A tool is installed through an [extension](/agents/docs/extensions), and by default every conversation uses every installed extension. - The model reads the tool's `description` and calls it with arguments that match `parameters`. Pi rejects a call whose arguments don't match. - The model reads `content` as the result. Clients get `details` for your UI. diff --git a/vendor/agents/docs/content/docs/extensions.mdx b/vendor/agents/docs/content/docs/extensions.mdx new file mode 100644 index 00000000..e9afa7f3 --- /dev/null +++ b/vendor/agents/docs/content/docs/extensions.mdx @@ -0,0 +1,102 @@ +--- +title: "Extensions" +description: "Bundle tools, prompt sections, hooks, and tasks into named extensions, and choose which ones each conversation uses." +--- + +An extension is a named bundle of what an agent runs with: tools, prompt sections, hooks, tool wrappers, and tasks. You install extensions in a registry and pass it to `pi()`. Each conversation stores the names of the extensions it uses. + +
+ + + + + + YOUR WORKER + Registry + + + + + + + coding-tools + audit + read-only + + + + + Conversation A + coding-tools, audit + Conversation B + coding-tools, audit, read-only + + + + + + by name + +
+ +| Field | Adds | See | +| --- | --- | --- | +| `tools` | Functions the model can call. | [Custom Tools](/agents/docs/custom-tools) | +| `sections` | Parts of the system prompt. | [Instructions](/agents/docs/instructions) | +| `hooks` | Code that runs around model requests, tool calls, and compaction. | [Hooks](#hooks) | +| `wraps` | Decorators for a tool or section, by name. | [Wrap a tool](#wrap-a-tool) | +| `tasks` | Multi-step work that resumes after a crash. | [Tools and tasks](/agents/docs/pi#tools-and-tasks) | + +By default, every conversation uses every installed extension, in install order. When two selected extensions have a tool with the same name, the later one wins. + +## Hooks + +A hook runs inside a built-in task, such as a tool call, in every conversation that selects its extension. This extension blocks the tools that change files or run commands: + + + +| Hook | Task | Runs | Can | +| --- | --- | --- | --- | +| `beforeTool` | `ToolTask` | Before a tool call runs. | Block the call or rewrite its arguments. A throw also blocks it. | +| `afterTool` | `ToolTask` | After a tool call returns. | Replace the result. | +| `beforeRequest` | `GenerationTask` | Before every model request. | Replace the messages of that request only. | +| `afterResponse` | `GenerationTask` | After every model response. | Observe it. | +| `afterTools` | `GenerationTask` | After every tool call of a round finishes. | Observe the results. | +| `onYield` | `GenerationTask` | When the model gives a final answer. | Continue the run with another user message. | +| `beforeCompact` | `CompactionTask` | Before a compaction summarizes the conversation. | Decline it or supply your own summary. | + +Hooks run in the agent Actor on your worker, so they can call your APIs. To enforce a rule in every conversation, keep its extension in the default selection. To approve tool calls one by one, see [Human in the Loop](/agents/docs/human-in-the-loop). + +## Wrap a tool + +`wrapTool` decorates a tool by name, whichever extension supplied it. This one logs every `bash` command: + + + +`wrapSection` does the same for a prompt section. + +## Choose extensions per conversation + + + + + + +- **`settings.extensions`** is the default selection. Without it, conversations use every installed extension. +- **`{ add, remove }`** changes the default for one conversation. An array selects exactly those extensions, in order. `null` goes back to the default. +- **Clients pass names.** A name that isn't installed rejects with a `UserError`. +- **Changes apply to the next step.** A model request that already started keeps its tools and prompt. A tool call that hasn't started yet uses the new hooks. + +The `conversation.agent` action returns the conversation's extensions, tools, and prompt sections by name. + +## Deploys + +The registry lives in your worker's code, and every agent Actor on that worker shares it. Pi saves only the names of the extensions a conversation selects. + +- **Install extensions at startup.** Installing one later changes only the worker that runs the code. +- **Keep names stable.** Renaming an extension is the same as removing it. +- **A removed extension stops applying.** Conversations that select it lose its tools, sections, and hooks, and get them back when a later deploy installs it again. +- **Tasks wait for their extension.** A task from an extension's `tasks` resumes only once that extension is installed, so keep it installed while its tasks can run. +- **Running tool calls finish on the old code** while the Actor drains. See [Upgrades and crashes](/agents/docs/pi#upgrades-and-crashes). + +**Next:** [User Subscriptions](/agents/docs/user-subscriptions), how users run agents on their own Claude or ChatGPT plan. diff --git a/vendor/agents/docs/content/docs/pi.mdx b/vendor/agents/docs/content/docs/pi.mdx index 6b1a14e1..fdf9ccd0 100644 --- a/vendor/agents/docs/content/docs/pi.mdx +++ b/vendor/agents/docs/content/docs/pi.mdx @@ -203,7 +203,7 @@ The `pi()` function accepts every [`actor()`](/actors/docs/actor-configuration) | Option | Description | | --- | --- | -| `registry` | Required. Your tools and tasks, from `createRegistry()`. | +| `registry` | Required. Your tools and tasks, from `createRegistry()`. See [Extensions](/agents/docs/extensions). | | `model` | Starting model of new conversations, as `provider/modelId`. | | `scopedModels` | Models a client may switch to. | | `apiKeys` | API keys by provider. Defaults to the environment. See [LLM API Keys](/agents/docs/api-keys). | diff --git a/vendor/agents/docs/content/docs/user-subscriptions.mdx b/vendor/agents/docs/content/docs/user-subscriptions.mdx index f63c5eb2..f127e8b4 100644 --- a/vendor/agents/docs/content/docs/user-subscriptions.mdx +++ b/vendor/agents/docs/content/docs/user-subscriptions.mdx @@ -45,7 +45,7 @@ Store each user's logins in a `credentials` Actor and pass it to `pi({ credentia The agent asks the `credentials` Actor on every model call, so a login or logout applies to the next model call, also in a run that is already going. The agent Actor never receives a refresh token and never writes credentials back. Errors from the `credentials` Actor reject the model call that needed the credential, so keep secrets out of their messages. -Your app needs its own login flow, such as a settings page, that saves the result with the `credentials` Actor's `save` action. The files above are in `examples/docs/user-subscriptions`. +Your app needs its own login flow, such as a settings page, that saves the result with the `credentials` Actor's `save` action. For a complete ChatGPT login flow, see [Sign in with ChatGPT](/guides/sign-in-with-chatgpt). The files above are in `examples/docs/user-subscriptions`. ## Share credentials across a team diff --git a/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx new file mode 100644 index 00000000..004dcc34 --- /dev/null +++ b/vendor/agents/docs/content/guides/sign-in-with-chatgpt.mdx @@ -0,0 +1,143 @@ +--- +title: "Sign in with ChatGPT" +description: "Let users connect their ChatGPT plan so your agents run on it, with no OpenAI API key." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + +
+ + + + + + + + + + + User + chatgptLogin + OpenAI + credentials + + + + + + + + + start() + sign-in URL + sign in, Continue + redirect to 127.0.0.1 + finish(url) + exchange code + tokens + save login + + + + + + + + + + + + + + +
+ +What we'll build: + +- A **Continue with ChatGPT** flow that connects a user's ChatGPT Plus or Pro plan to your app. +- A `chatgptLogin` Actor per user that runs the sign-in with pi-ai and saves the result. +- A `credentials` Actor per user that keeps the login and refreshes its access token. +- An agent that runs on the user's ChatGPT plan, with no OpenAI API key. + +Related docs: + +- [User Subscriptions](/agents/docs/user-subscriptions), how agents read logins from a `credentials` Actor. +- [LLM API Keys](/agents/docs/api-keys), the order the agent looks for credentials in. +- ChatGPT plan usage, OpenAI's guide to the flow. + + +This flow is for open-source and self-hosted apps. To offer it in a paid or hosted app, fill out OpenAI's interest form. + + + + + + +```sh +npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/pi-coding-agent rivetkit +``` + + + + + +OpenAI identifies each deployment of your app by an agent host ID. Generate a UUID once and set it on every worker: + +```sh +export CHATGPT_HOST_ID=$(node -e 'console.log(crypto.randomUUID())') +``` + +Keep the same value across deploys. It isn't a secret. + + + + + +The `credentials` Actor holds each user's login. It gives the agent the access token, never the refresh token, and refreshes it when it's about to expire: + + + + + + + +The `chatgptLogin` Actor drives pi-ai's Sign in with ChatGPT flow: + + + +- `start` returns the URL to open. Calling it again cancels the sign-in in progress. +- OpenAI sends the browser back to `http://127.0.0.1:1455/auth/callback`. When the worker runs on another machine, that page doesn't load, and the user pastes its URL into your app. +- `finish` exchanges the code and saves the login with the provider id `openai`. +- A sign-in that isn't finished within ten minutes is cancelled. + + + + + + + +The agent reads the user's credential before every model call, so it can use OpenAI models as soon as the user signs in. + + + + + + + +In a web app, open the URL in a new tab and show a field for the pasted URL. Label the button **Continue with ChatGPT**, as OpenAI's guidelines require. + + + + + +## Limitations + +- **One sign-in at a time per worker.** pi-ai listens on port 1455 during a sign-in, so a second user's `start` on the same worker fails until the first finishes or is cancelled. +- **Only the Responses API.** ChatGPT plan tokens can't call OpenAI classifier models. +- **Usage counts against the user's plan.** Users set limits for your app in their ChatGPT settings. + + +Protect both Actors with [authentication](/docs/authentication). `credentials` returns access tokens, and anyone who can call `finish` can save a login to that user. + diff --git a/vendor/agents/docs/sidebar.json b/vendor/agents/docs/sidebar.json index 76054f1c..f83ac9e8 100644 --- a/vendor/agents/docs/sidebar.json +++ b/vendor/agents/docs/sidebar.json @@ -69,6 +69,10 @@ "title": "Instructions", "href": "/agents/docs/instructions" }, + { + "title": "Extensions", + "href": "/agents/docs/extensions" + }, { "title": "User Subscriptions", "href": "/agents/docs/user-subscriptions" @@ -169,5 +173,17 @@ } ] } + ], + "guides": [ + { + "title": "Agents", + "pages": [ + { + "title": "Sign in with ChatGPT", + "href": "/guides/sign-in-with-chatgpt", + "icon": "faOpenai" + } + ] + } ] } diff --git a/vendor/agents/examples/docs/extensions/audit.ts b/vendor/agents/examples/docs/extensions/audit.ts new file mode 100644 index 00000000..d1ed4b09 --- /dev/null +++ b/vendor/agents/examples/docs/extensions/audit.ts @@ -0,0 +1,16 @@ +import { defineExtension, wrapTool } from "@earendil-works/pi-durable"; +import { createBashTool } from "@earendil-works/pi-durable/tools"; + +export const audit = defineExtension({ + name: "audit", + wraps: [ + // Wraps whichever `bash` tool the conversation ends up with. + wrapTool(createBashTool(), (bash) => ({ + ...bash, + execute: (args, api, context) => { + console.log(`[audit] ${api.conversationId}: ${args.command}`); + return bash.execute(args, api, context); + }, + })), + ], +}); diff --git a/vendor/agents/examples/docs/extensions/client.ts b/vendor/agents/examples/docs/extensions/client.ts new file mode 100644 index 00000000..375cfd4a --- /dev/null +++ b/vendor/agents/examples/docs/extensions/client.ts @@ -0,0 +1,16 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient(); +const agent = client.agent.getOrCreate(["acme", "checkout-review"]); +const root = await agent.harness.root(); + +// Add read-only to this conversation only. Extensions are passed by name. +await agent.conversation.configure(root.id, { extensions: { add: ["read-only"] } }); +const answer = await agent.prompt("Where are checkout totals computed?"); +console.log(answer.status === "done" ? answer.text : `Unanswered: ${answer.reason}`); + +// Go back to the default selection, CodingTools and audit. +await agent.conversation.configure(root.id, { extensions: null }); +const { extensions } = await agent.conversation.agent(root.id); +console.log(extensions); // ["coding-tools", "audit"] diff --git a/vendor/agents/examples/docs/extensions/read-only.ts b/vendor/agents/examples/docs/extensions/read-only.ts new file mode 100644 index 00000000..c599771c --- /dev/null +++ b/vendor/agents/examples/docs/extensions/read-only.ts @@ -0,0 +1,14 @@ +import { defineExtension, hook, section, ToolTask } from "@earendil-works/pi-durable"; + +const changesFiles = new Set(["write", "edit", "bash"]); + +export const readOnly = defineExtension({ + name: "read-only", + sections: [section("mode", () => "You are in read-only mode. Read files and explain them, but do not change anything.")], + hooks: [ + hook(ToolTask, { + // A blocked call never runs. The model gets the message as an error result. + beforeTool: (call) => (changesFiles.has(call.name) ? { block: `${call.name} is turned off in read-only mode.` } : undefined), + }), + ], +}); diff --git a/vendor/agents/examples/docs/extensions/server.ts b/vendor/agents/examples/docs/extensions/server.ts new file mode 100644 index 00000000..1e7536ca --- /dev/null +++ b/vendor/agents/examples/docs/extensions/server.ts @@ -0,0 +1,24 @@ +import { createRegistry } from "@earendil-works/pi-durable"; +import { CodingTools } from "@earendil-works/pi-durable/tools"; +import { pi } from "@rivet-dev/pi"; +import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b"; +import { setup } from "rivetkit"; +import { audit } from "./audit"; +import { readOnly } from "./read-only"; + +const extensions = createRegistry(); +extensions.install(CodingTools); +extensions.install(audit); +extensions.install(readOnly); + +const agent = pi({ + model: "anthropic/claude-opus-5-5", + registry: extensions, + sandbox: e2bProvider(), + // New conversations start without read-only. A client turns it on per conversation. + settings: { extensions: [CodingTools, audit] }, +}); + +export const registry = setup({ use: { agent } }); + +registry.start(); diff --git a/vendor/agents/examples/docs/react-sdk/useAgentChat.ts b/vendor/agents/examples/docs/react-sdk/useAgentChat.ts index 5b7275df..4b273060 100644 --- a/vendor/agents/examples/docs/react-sdk/useAgentChat.ts +++ b/vendor/agents/examples/docs/react-sdk/useAgentChat.ts @@ -1,5 +1,5 @@ import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai"; -import type { AgentEvent, EntryRecord, SnapshotEvent } from "@earendil-works/pi-durable"; +import type { AgentEvent, ConversationId, EntryRecord, SnapshotEvent } from "@earendil-works/pi-durable"; import { createRivetKit } from "@rivetkit/react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { registry } from "../pi/server"; @@ -11,7 +11,7 @@ export type ChatMessage = { id: string; role: "user" | "assistant"; text: string export function useAgentChat(key: string[]) { const agent = useActor({ name: "agent", key }); const conn = agent.connection; - const [rootId, setRootId] = useState(null); + const [rootId, setRootId] = useState(null); const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(""); const [tool, setTool] = useState(null); diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts new file mode 100644 index 00000000..f024aedb --- /dev/null +++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/chatgpt-login.ts @@ -0,0 +1,78 @@ +import { type Credential, createModels, InMemoryCredentialStore } from "@earendil-works/pi-ai"; +import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; +import { actor, type Registry, UserError } from "rivetkit"; +import type { credentials } from "./credentials"; + +// A UUID you generate once for this deployment. OpenAI calls it the agent host ID. +const HOST_ID = process.env.CHATGPT_HOST_ID ?? ""; +// Your app's name. Users see it on the ChatGPT consent screen. +const APP_NAME = "Acme"; + +interface PendingSignIn { + cancel: () => void; + pasteRedirect: (url: string) => void; + credential: Promise; +} + +// One per user. Runs one sign-in at a time and saves the result to the user's credentials Actor. +export const chatgptLogin = actor({ + createVars: () => ({ pending: undefined as PendingSignIn | undefined }), + actions: { + // Starts a sign-in and returns the URL to open in the user's browser. + start: async (c) => { + c.vars.pending?.cancel(); + + const models = createModels({ credentials: new InMemoryCredentialStore() }); + models.setProvider(openaiProvider()); + + const cancel = new AbortController(); + const authUrl = Promise.withResolvers(); + const redirect = Promise.withResolvers(); + const credential = models.login( + "openai", + "oauth", + { + // Give up after ten minutes, or when the user starts over. + signal: AbortSignal.any([cancel.signal, AbortSignal.timeout(10 * 60_000)]), + notify: (event) => { + if (event.type === "auth_url") authUrl.resolve(event.url); + }, + // pi-ai asks for the redirect URL the browser landed on. Rejecting on abort + // lets pi-ai close its callback server, so the next sign-in can start. + prompt: ({ signal }) => + new Promise((resolve, reject) => { + signal?.addEventListener("abort", () => reject(new Error("Sign-in cancelled.")), { once: true }); + redirect.promise.then(resolve); + }), + }, + { getDeviceId: () => HOST_ID, agentName: APP_NAME }, + ); + + c.vars.pending = { cancel: () => cancel.abort(), pasteRedirect: redirect.resolve, credential }; + // Stay awake until the sign-in finishes, fails, or times out. + c.keepAwake(credential.catch(() => undefined)); + + // Rejects here if the sign-in fails before it has a URL. + return Promise.race([authUrl.promise, credential.then(() => authUrl.promise)]); + }, + + // Finishes the sign-in with the redirect URL the user pasted. Without one, it waits + // for the browser to reach the callback on its own, which works only when the worker + // runs on the same machine as the browser. + finish: async (c, redirectUrl?: string) => { + const pending = c.vars.pending; + if (!pending) throw new UserError("Start a sign-in first."); + if (redirectUrl) pending.pasteRedirect(redirectUrl); + + try { + const credential = await pending.credential; + const client = c.client>(); + await client.credentials.getOrCreate([c.key[0]]).save("openai", credential); + } catch (error) { + throw new UserError(error instanceof Error ? error.message : "ChatGPT sign-in failed."); + } finally { + c.vars.pending = undefined; + } + }, + }, +}); diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts new file mode 100644 index 00000000..8b6e1d53 --- /dev/null +++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/client.ts @@ -0,0 +1,21 @@ +import { createInterface } from "node:readline/promises"; +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +const client = createClient(); +const userId = "user-123"; + +// 1. Start the sign-in and send the user to ChatGPT. +const login = client.chatgptLogin.getOrCreate([userId]); +const url = await login.start(); +console.log(`Open this URL and choose Continue:\n${url}\n`); + +// 2. The browser lands on a 127.0.0.1 URL. The user copies it from the address bar. +const terminal = createInterface({ input: process.stdin, output: process.stdout }); +const redirect = await terminal.question("Paste the URL you landed on, or press Enter if the page says you can close it: "); +terminal.close(); +await login.finish(redirect.trim() || undefined); + +// 3. The agent now runs on the user's ChatGPT plan. +const result = await client.agent.getOrCreate([userId, "chat"]).prompt("Say hi in five words."); +console.log(result.status === "done" ? result.text : `Unanswered: ${result.reason}`); diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts new file mode 100644 index 00000000..062e4f82 --- /dev/null +++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/credentials.ts @@ -0,0 +1,31 @@ +import { type Credential, InMemoryCredentialStore } from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; +import type { PiProviderCredential } from "@rivet-dev/pi"; +import { actor } from "rivetkit"; + +// One per user. Holds the user's ChatGPT login and gives the agent fresh access tokens. +export const credentials = actor({ + state: { saved: {} as Record }, + actions: { + save: (c, provider: string, credential: Credential) => { + c.state.saved[provider] = credential; + }, + list: (c) => Object.entries(c.state.saved).map(([providerId, { type }]) => ({ providerId, type })), + read: (c, provider: string) => withoutRefreshToken(c.state.saved[provider]), + refresh: async (c, provider: string) => { + const store = new InMemoryCredentialStore(); + await store.modify(provider, async () => c.state.saved[provider]); + const runtime = await ModelRuntime.create({ credentials: store, modelsPath: null }); + await runtime.getAuth(provider, { minOAuthValidityMs: 10 * 60_000 }); + const refreshed = await store.read(provider); + if (refreshed) c.state.saved[provider] = refreshed; + return withoutRefreshToken(refreshed); + }, + }, +}); + +function withoutRefreshToken(credential: Credential | undefined): PiProviderCredential | undefined { + if (credential?.type !== "oauth") return credential; + const { refresh: _refresh, ...rest } = credential; + return rest; +} diff --git a/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts b/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts new file mode 100644 index 00000000..7b3fee35 --- /dev/null +++ b/vendor/agents/examples/docs/sign-in-with-chatgpt/server.ts @@ -0,0 +1,19 @@ +import { createRegistry } from "@earendil-works/pi-durable"; +import { pi } from "@rivet-dev/pi"; +import { type Registry, setup } from "rivetkit"; +import { chatgptLogin } from "./chatgpt-login"; +import { credentials } from "./credentials"; + +const agent = pi({ + model: "openai/gpt-6-sol", + registry: createRegistry(), + // The agent's key starts with the user id, so it runs on that user's ChatGPT plan. + credentials: (c) => { + const client = c.client>(); + return client.credentials.getOrCreate([c.key[0]]); + }, +}); + +export const registry = setup({ use: { credentials, chatgptLogin, agent } }); + +registry.start();