Pi Durable

Pi Durable

@ai_longreads

Earendil представляет Pi Durable — экспериментальный фреймворк для создания долгоживущих, устойчивых к сбоям и гибких агентов, способных работать в любом JavaScript-окружении.

Это AI-перевод статьи, сделанный каналом Про AI: Лучшие Статьи и Исследования.


Pi Durable

Pi Durable Автор: Earendil Engineering Оригинальный текст:

Сегодня Earendil и сообщество Pi выпустили Pi 1.0. Этот релиз отражает нашу уверенность в том, что после бесчисленных часов укрепления, поддержки и активной разработки Pi стал надёжным фундаментом для создания приложений. Pi продолжает развиваться. Вместе с Pi 1.0 мы выпускаем экспериментальный новый пакет — Pi Durable. Pi Durable создан специально для долгоживущих, устойчивых и гибких агентов, способных работать где угодно. Мы приглашаем вас присоединиться и помочь нам сделать его лучшей durable-оболочкой из существующих.

Зачем нужен Pi Durable?

Pi как агент для кодирования создан для работы на вашей (удалённой) машине, внутри терминала, под управлением одного человека. Если процесс падает, вы смотрите, что произошло, и просите продолжить. Именно на этом сфокусирован Pi 1.0, именно в этом он силён, и это не изменится.

В Earendil мы хотим сделать эту технологию доступной каждому, в любой форме, которая подходит под конкретные задачи. Для этого нам нужна оболочка (harness), которая работает где угодно, доступна с разных поверхностей, поддерживает бесконечно долгие разговоры, переживает катастрофические внутренние и внешние сбои и позволяет нескольким людям управлять одними и теми же агентами.

Pi Durable — это и есть такая оболочка. Он не заменяет агент Pi для кодирования. Это фреймворк для создания любых агентных приложений, включая агенты для кодирования. Он делит не только код с агентом Pi, например pi-ai, но и его принципы: минимализм и гибкость.

Кроме того, Pi Durable позволяет нам исследовать новые архитектурные решения в этом пространстве, не нарушая работу основного агента Pi. Уроки, которые мы извлечём при создании агентных приложений на Pi Durable, вернутся в агент Pi по мере того, как докажут свою ценность.

Что такое оболочка (harness)?

У каждого своё определение оболочки. Мы писали об этом ранее, но давайте заново представим концепцию оболочки в контексте Pi Durable.

Оболочка — это хранилище плюс механизмы, необходимые для параллельного ведения одного или нескольких разговоров с большими языковыми моделями. Она предоставляет инструменты, которые вызывают эти модели, и среды выполнения, в которых работают инструменты.

Разговор — это взаимодействие между вами и агентом, записанное в виде транскрипта. Агент — это большая языковая модель вместе с её настройками, такими как уровень reasoning, и инструментами, которые она может вызывать.

Инструменты выполняют свою работу через среду выполнения, которой может быть ваш ноутбук, удалённая виртуальная машина или песочница в памяти. Какие инструменты и какую среду выполнения получает агент — зависит от каждого конкретного разговора.

Всё, что запускает оболочка — от вызова модели до выполнения инструмента — является задачей (task).

Как и всё в Pi, Pi Durable создан так, чтобы ваш агент мог его понять. Весь исходный код, без тестов, составляет около 15 000 строк, что выходит примерно в 150 000 токенов для GPT и около 250 000 для Claude. Это худший случай. Для работы с Pi Durable вашему агенту редко нужен весь код целиком; одни только бэкенды хранения — это 3 000 строк, которые обычно можно пропустить.

А теперь давайте проведём небольшую экскурсию по Pi Durable, чтобы показать, что мы создали и почему.

Долгие запуски где угодно

Мы хотим, чтобы агенты работали долго и могли запускаться где угодно, где «где угодно» на данный момент означает любое место, где есть JavaScript-рантайм.

В Pi Durable оболочка открывается поверх бэкенда хранения. Pi Durable поставляется с хранилищами в памяти, SQLite и JSONL, а также с набором тестов на соответствие и бенчмарками для вашего собственного бэкенда. Код хранилищ SQLite и JSONL не использует API Node, поэтому с небольшим адаптером работает на Bun или внутри Cloudflare Durable Object. Интерфейс хранилища компактен и прост в реализации поверх того, что у вас есть, будь то key-value хранилище или Postgres. Один процесс владеет хранилищем в каждый момент времени, а другие клиенты подключаются к этому процессу.

На SQLite оболочка хранит в памяти только рабочий набор: активные транскрипты, текущие задачи и ожидающие отправки. Всё остальное остаётся на диске до тех пор, пока не понадобится. Активные транскрипты естественным образом ограничены контекстным окном модели, потому что компактификация суммаризирует старые сообщения до того, как они переполнят его. Так что даже разговор с десятками тысяч сообщений комфортно помещается в памяти.

Инструменты, которым нужны файлы или шелл, получают их из среды выполнения. Pi Durable поставляется со средой выполнения Node, которая даёт инструментам доступ к вашим локальным файлам. Как и хранилище, интерфейс среды выполнения компактен и прост в реализации, поэтому вы можете подключать и удалённые среды выполнения к своим инструментам. Это позволяет оболочке работать на одной машине, а инструментам — на другой. Ваша функция env формирует среду для каждого вызова инструмента из рабочей директории разговора, так что каждый разговор может работать в своём месте.

import from "@earendil-works/chord/context"
import from "@earendil-works/pi-ai/models"
import from "@earendil-works/pi-ai/providers/openai"
import from "@earendil-works/pi-durable"
import from "@earendil-works/pi-durable/env/node"
import from "@earendil-works/pi-durable/storage/sqlite/node"
import from "@earendil-works/pi-durable/tools"

const // every call takes a context for cancellation
const // read, write, edit, bash

const new
const await "./agent.sqlite"

// Корневой разговор: создаётся при первом использовании и остаётся
// тем же после каждого перезапуска.
const await "openai" "gpt-6.1-sol" "/work/repo"

Переживает сбои

Мы хотим, чтобы агент переживал гибель своего процесса — будь то засыпание ноутбука, передеплой контейнера или нехватка памяти на машине — и продолжал с того места, где остановился.

В Pi Durable каждый шаг запуска — это задача, которая сохраняет чекпоинт перед тем, как двигаться дальше. Если процесс падает, новый процесс открывает то же хранилище, находит незавершённые задачи и продолжает каждую с её последнего чекпоинта. Запрос к модели, который был прерван, отправляется снова; частичный ответ остаётся в транскрипте, помеченный как прерванный. Вызов инструмента, который был прерван, перезапускается, если это безопасно; в противном случае модели сообщается, что он был прерван. В Pi Durable нет встроенных субагентов, но их можно построить за несколько строк кода, как показывает пример с инструментом триажа ниже. Субагент работает в собственном разговоре, поэтому он тоже продолжает с того места, где остановился, а инструмент субагента, который безопасно перезапустить, находит своего субагента и ждёт его ответа. Сообщения в очереди остаются в очереди. requestId делает отправку exactly-once, поэтому клиент, который повторяет запрос после сбоя, получает исходную отправку обратно вместо того, чтобы спрашивать дважды.

const type "input" "Fix the flaky login test" "job-42"
as const await
// Процесс падает здесь, в середине вызова инструмента.

// Новый процесс открывает то же хранилище.
const await "./agent.sqlite"

// продолжить прерванный запуск
const await
// та же отправка, с ответом
const await

Много разговоров одновременно

Мы хотим, чтобы одна оболочка вела много разговоров одновременно, без взаимных блокировок.

В Pi Durable одна оболочка ведёт столько разговоров, сколько нужно, параллельно, с теми же гарантиями. Разговор начинается с нуля или ответвляется от другого в любой точке его транскрипта и видит историю родителя до этой точки, не копируя её.

Представьте Slack-канал, в котором ваш агент отвечает на упоминания от любого участника. Затем кто-то открывает тред. Канал может быть одним разговором, а тред — его ответвлением (fork) в точке сообщения, на которое отвечает тред. Оба работают одновременно и не блокируют друг друга.

const await
const await type "input" "@agent why did the deploy fail?"
const await

// Кто-то отвечает на ответ агента в треде. Каждый разговор указывает
// своего владельца, что определяет область действия abort (подробнее в
// разделе Tasks). У треда владельца нет.
const await "ownerless"

// Оба разговора работают одновременно.
const await type "input" "@agent can we roll it back?"
const await type "input" "@agent who is on call today?"
await

Каждый разговор также хранит своего агента: модель, уровень reasoning, выбранные расширения и какие из их инструментов активны, дополнительные инструкции и рабочую директорию в среде выполнения. Рецензент рядом с основным агентом может использовать более дешёвую модель, инструменты только для чтения и собственный checkout.

Расширения

Мы хотим, чтобы всё, что может делать агент, было подключаемым, и каждый подключённый элемент участвовал в обеспечении устойчивости.

В Pi Durable расширение — это именованный набор секций системного промпта, инструментов, хуков и задач. Приложение устанавливает расширения в реестре. Каждый разговор выбирает, какие расширения и инструменты использовать, и хранит только их имена.

Секции системного промпта

Системный промпт перестраивается из секций расширений разговора перед каждым запросом, поэтому изменённая секция подхватывается следующим запросом. Pi Durable записывает, что изменилось, в транскрипт — в ту позицию, где произошло изменение — чтобы перезапуск или ответвление видели именно то, что видела модель. На моделях, поддерживающих изменения системного промпта и инструментов в середине разговора, отправляется только изменение, так что prompt cache остаётся валидным.

import from "@earendil-works/pi-durable"

const "project-context"
// Читается из среды выполнения разговора. Файлы могут быть
// загружены и отслеживаться в фоне; каждый запрос рендерит
// последнее состояние.
"agents_md" "skills"

Инструменты

Каждый вызов инструмента запускается как отдельная durable-задача, и его намерение (intent) сохраняется перед выполнением. После сбоя инструмент перезапускается, только если он указал, что это безопасно. В противном случае модели сообщается, что вызов был прерван, с выводом, сохранённым до этого момента, и модель решает, что делать. Каждый разговор может также получить свой собственный набор инструментов — например, тред из Slack, который может искать, но не деплоить.

import from "@earendil-works/pi-ai"
import from "@earendil-works/pi-durable"

const "search_issues" "Search the issue tracker" "safe"
// только читает, поэтому повторный запуск после сбоя безопасен
// стримится каждому клиенту, наблюдающему за разговором
`searching for ${}\n`
return type "text"

const "deploy" "Deploy a version to production"
// Без повтора: деплой, прерванный сбоем, сообщается модели,
// но никогда не повторяется.
type "text" "ops"

// Тред может искать, но не деплоить.
await

Инструмент получает API оболочки для своего вызова: он может фиксировать записи и документы, запускать задачи и разговоры, а также общаться с другими разговорами. Это позволяет реализовать субагента за несколько строк кода. Инструмент создаёт разговор, которым владеет, даёт ему меньшую модель и собственные инструкции и ждёт его ответа. Субагент — это разговор как любой другой, поэтому он переживает сбой, считает свои затраты, а UI может показать его под вызовом.

import type from "@earendil-works/pi-ai"
import from "@earendil-works/pi-durable"

const "triage" "Label an incoming issue as bug, feature, or question"
// повторный запуск после сбоя находит того же субагента и ту же отправку
"safe"

const await async
const await 1 0
if undefined return
// Принадлежит этому вызову, поэтому отмена вызова отменяет субагента.
const await "task"
// Начинает как копия агента текущего разговора. Делаем его маленькой
// моделью без инструментов.
await "openai" "gpt-6-luna" "Answer with one word: bug, feature, or question."
return // позволяет UI показать субагента под вызовом
await

const await
const type "input" `triage:${}` as const
const await

// Ответ — это запись в транскрипте субагента. Читаем его и берём
// текст.
const await
const 0 as const type "text" ""
return type "text"

Расширения также могут изменять инструменты других расширений. Инструмент с тем же именем в более позднем расширении заменяет предыдущий — например, bash, который запускается внутри виртуального окружения Python. Обёртка (wrap) декорирует тот инструмент, который победил, независимо от того, где в порядке выбрано обёртывающее расширение.

import from "@earendil-works/pi-durable"
import from "@earendil-works/pi-durable/tools"

// Замеряет время каждого вызова bash, какой бы bash ни оказался в разговоре.
const "timing"
const
try return await
finally "bash"

Хуки

Хуки позволяют расширениям встраиваться в задачи, включая встроенные задачи для генерации ответа модели, вызова инструмента или выполнения компактификации. Они могут переписать запрос перед отправкой модели, заблокировать или переписать вызов инструмента, заменить результат, продолжить запуск или написать саммари самостоятельно. Хук может быть запущен повторно после сбоя, поэтому хук, принимающий решение, сохраняет его в memo: небольшом значении, хранящемся вместе с задачей, где первая запись побеждает.

import from "@earendil-works/pi-durable"

const "approval"
if "deploy" return undefined
// После перезапуска хук находит сохранённый ответ вместо
// повторного запроса.
let await "approval:deploy"
await "approval:deploy"
await return undefined "Nobody approved the deploy."

Несколько расширений могут подключиться к одному и тому же событию. Их хуки выполняются цепочкой в порядке, в котором разговор выбирает расширения, и каждый хук определяет, как работает его цепочка. beforeTool передаёт переписанные аргументы дальше по цепочке, и первая блокировка останавливает её. afterTool передаёт результат дальше по цепочке. onYield останавливается на первом хуке, который продолжает запуск. Наблюдатели вроде afterResponse всегда запускают все хуки. Хук, выбрасывающий исключение, сообщается, и цепочка продолжается, за исключением beforeTool, где исключение блокирует вызов.

Задачи

Оболочка ведёт разговоры с помощью встроенных задач: одна для каждого запроса к модели, одна для каждого вызова инструмента и одна для компактификации. Расширения приносят собственные задачи и получают тот же механизм: чекпоинт после каждого шага, таймеры, переживающие перезапуски, и ожидание других задач.

Оформление заказа, распределяющего оплату между несколькими картами, заряжает все карты одновременно. Если одна карта отклонена, остальные платежи отменяются и возвращают средства:

import type from "@earendil-works/pi-durable"

const "charge" "shop.payment" "charge"
// Ключ делает списание идемпотентным: если сбой повторит эту
// фазу, карта будет списана только один раз.
const await `payment-${}`
await "terminal" "completed" "failed"
// Другой платёж не прошёл, или оформление отменено: отменить этот.
await `payment-${}`
await "terminal" "aborted"

type "pay" "decide"
const "shop.checkout" "pay"
await async
const for const of
await "task"
// Не выполняет код, пока каждый платёж не завершён. Первый
// неудавшийся платёж отменяет остальные.
return "waiting" "decide" "failFast"
const await
const "completed"
await "terminal" "completed" "Order placed."
"failed" "terminal" "aborted"

// Агент запускает оформление заказа с помощью инструмента.
const "checkout" "Pay for the cart, split across several cards"
// Принадлежит этому вызову: отмена вызова отменяет оформление
// и возвращает его платежи.
const "task" as const
const await
const await
const "completed"
return type "text" "shop"

Задачи и разговоры формируют единое дерево владения. Отмена задачи отменяет то, чем она владеет, снизу вверх, так что каждая задача сначала очищает собственные эффекты, и задача завершается только после завершения принадлежащей ей работы. Субагент — тот же паттерн: разговор, принадлежащий вызову инструмента, который его запустил.

Задачи по умолчанию работают на переднем плане: они являются частью текущей работы разговора. Разговор переходит в состояние ожидания, только когда они завершены, и отмена разговора, например когда пользователь нажимает Esc, отменяет их и всё, чем они владеют. Фоновая задача принадлежит разговору, но не его текущей работе. Разговор переходит в ожидание, пока она работает, и обычная отмена оставляет её и всё, чем она владеет, нетронутыми. Это подходит для субагента, который должен пережить породивший его ход, или для напоминания, которое сработает завтра. Отмена самой задачи или разговора с { background: true } всё равно останавливает её.

// Часть текущей работы: Esc отменяет её, и разговор ждёт её завершения.
await "task"
// Побочная работа: разговор переходит в ожидание, пока она работает,
// и Esc её не трогает.
await "conversation"

Компактификация

Мы хотим, чтобы длинные разговоры продолжались, не требуя от агента остановки для суммаризации.

В Pi Durable компактификация — это задача, как и любая другая, и она выполняется, пока разговор продолжается. Когда контекст приближается к лимиту модели, фоновая компактификация суммаризирует старые сообщения, и саммари размещается на границе следующего хода. Разговор ждёт саммари, только когда следующий запрос иначе не поместится. Если провайдер всё равно отклоняет запрос как слишком длинный, оболочка компактифицирует и повторяет попытку один раз. Вы также можете компактифицировать вручную в любой момент, со своими собственными инструкциями. Старые сообщения всегда остаются в хранилище.

const await
// за пределами contextWindow - reserveTokens, следующий запрос ждёт
// саммари
// за столько до этого начинается фоновая суммаризация
// Вручную, в том числе пока агент работает.
await "Keep the names of the failing tests"

reset() идёт дальше: он начинает новый контекст, опционально с заметки о передаче (handoff note), а инструмент может запросить то же самое, вернув control: { handoff }. Поскольку ничего не удаляется, второй инструмент всё ещё может найти всё, что было до передачи. Это всё, что нужно для создания агента, который передаёт работу самому себе и потом ищет информацию.

const "handoff" "Start over from a handoff note."
"Older messages stay searchable with search_history."
// Поставлен в очередь после передачи, поэтому начинает следующий
// запуск в новом контексте.
const await
await type "input" "Continue." `handoff:${}`

// Завершает этот запуск и начинает новый контекст с заметки, как
// reset(note).
return type "text" "Handing off."

const "search_history" "Search older messages, including those before a handoff" "safe"
// Инструменты тоже читают записи через транзакцию. Тот, что ничего
// не пишет, ничего не сохраняет.
const await 200
const
const "\n"
return type "text"

Устойчивое состояние приложения

Мы хотим, чтобы состояние приложения, построенного на агенте, было таким же устойчивым, как и сам разговор.

В Pi Durable состояние приложения — будь то список дел, план, тикет или песочница, в которой работает разговор — хранится в документах. Документы — это типизированный JSON, хранящийся рядом с транскриптом и изменяемый в тех же атомарных коммитах, так что состояние никогда не расходится с транскриптом, который его породил. Каждый документ указывает, с чем начинает ответвление (fork): со значения родителя в точке ответвления, с его текущего значения или с нового.

import from "@earendil-works/pi-durable"

const "app.todos" "conversation" "rewindable" "asOf"
// ответвление начинает с теми задачами, которые были у родителя
// в точке ответвления

const "todo" "todo" "Add an item to your todo list"
await async
const await
const `Added ${}`
return type "text"

// Модель видит список перед каждым запросом.
"todos" async
const await
return "\n" undefined

// UI подписывается на зафиксированное значение.
const await

Гибкость

Мы хотим изменять код работающего агента без его остановки.

В Pi Durable реестр может изменяться, пока разговоры работают. Установка расширения под именем, которое уже установлено, заменяет его в один шаг. Вызов инструмента, который уже выполняется, завершается на коде, с которым он начинал; следующий вызов использует новый код. Разговоры хранят имена расширений и инструментов, но не код, поэтому после перезапуска они подхватывают то, что установил новый процесс.

// Файл расширения изменился на диске.
// то же имя "ops": заменяет установленное
await "./ops.ts"

Мультиплеер

Мы хотим, чтобы много людей и клиентов работали с одними и теми же разговорами одновременно: наблюдали за ними, подключались позже и управляли ими.

В Pi Durable всё, что нужно UI, — это зафиксированное состояние, поэтому любое количество клиентов может подключиться к любому разговору в оболочке. Клиент сначала получает текущий вид: транскрипт, стримящийся ответ, запущенные инструменты и их вывод, сообщения в очереди, агента и статистику использования. После этого он получает только изменения. Клиент, подключившийся позже или переподключившийся, начинает с текущего вида. Любой клиент может управлять работающим разговором или поставить сообщение в очередь.

// Второй клиент присоединяется к треду, пока агент работает.
const await
// И управляет им. Сообщение присоединяется к текущей работе после
// текущих вызовов инструментов.
await type "input" "Check the staging logs first" "steer"

Для удалённого клиента thread.watch() передаёт точные операции каждого коммита, достаточно компактные для отправки через сокет. Если вы предпочитаете привычные события агента для кодирования, watchEvents() преобразует коммиты в них, за счёт большего объёма данных по сети.

Попробуйте

Вы можете попробовать Pi Durable уже сегодня. Pi Durable экспериментален, и API ещё может измениться. Направьте своего агента на packages/durable в чекауте Pi, пусть он прочитает README, более тридцати примеров, небольшой агент для кодирования на Pi Durable или этот красивый агент для планирования отпуска — и начинайте строить.

Планировщик отпусков — это около 1 300 строк TypeScript, большинство из которых — TUI. Если он выглядит как агент для кодирования, это только потому, что он заимствует компоненты TUI у агента Pi.

  1. Планировщик отпусков с TUI, построенный на Pi Durable.
  2. Субагент запускает три поиска параллельно, каждый — durable-задача.
  3. Тем временем основной агент свободен для общения.
  4. Процесс падает. Погода и музеи готовы, поезда — нет.
  5. Перезапуск. search безопасен для повторного запуска: только поезда запускаются заново.
  6. Переключение на субагента и управление им.
  7. Обратно в main: запрос, компактификация и управление — всё пока он работает.
  8. Отчёт приходит как сообщение; main превращает его в план.

Чтобы запустить оба демо из чекаута Pi:

npm install && npm run build
node packages/coding-agent/src/experimental/durable/main.ts
node packages/coding-agent/src/experimental/vacation/main.ts

Чтобы использовать Pi Durable в собственном проекте:

npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord

В ближайшие недели мы расскажем больше о Pi Durable и покажем небольшие агентные инструменты, которые мы создаём с его помощью для собственной работы, например Slack-бот или GitHub-бот для триажа. Мы пока не хотим раскрывать все карты. Впереди ещё многое — мы используем Pi Durable так же, как используем Pi.

FAQ

Почему опять TypeScript?

Потому что это самый простой способ начать. Но, как все уже знают, портировать всё на Rust или ассемблер очень легко. Мы не исключаем этого в будущем, но сейчас сфокусированы на TypeScript.


Подпишитесь на канал и каждый день читайте лучшие материалы про AI переведенные на русский!

Нашли интересную статью для перевода? Пришлите нашему боту: @ailongreadsbot

Report Page