MODULE_04 · УРОК 4.5

Lifecycle hooks

Создать sandbox — полдела. Свежая cloud VM не знает ваш git user, не ставила зависимости, не видела .env. Перед работой агента что-то должно настроить среду; перед stop — сохранить WIP или snapshot. Это lifecycle hooks: для local пустые, для cloud — без них боль.

ЧТО ПОЛУЧИТСЯ SandboxLifecycle в sandbox.py, вызовы в main.py вокруг agent.run. С пустым lifecycle поведение как в 4.3; с логами — видно порядок вызовов.

Слова, которые встретятся

  • Lifecycle hook — колбэк на этапе жизни sandbox: старт, стоп, таймаут.
  • afterStart — sandbox готов, агент ещё не бежал: git config, uv sync, копия env.
  • beforeStop — перед выключением: коммит WIP, snapshot, лог «не забудь».
  • onTimeout — cloud сам дёрнет при expires_at (модуль 7); сейчас заглушка.

Быстрый путь

  1. Добавить SandboxLifecycle в sandbox.py.
  2. В main.py: try/finally + hooks.
  3. Временно повесить print-hooks и прогнать промпт.

Какие файлы трогаем

Файл Что делать
sandbox.py Править. Protocol SandboxLifecycle.
main.py Править. Вызов hooks + finally.
lifecycle.py Опционально позже — тела для cloud (npm install и т.д.).

Шаг 1. Интерфейс hooks

sandbox.py — добавить

class SandboxLifecycle(Protocol):
    async def after_start(self, sandbox: Sandbox) -> None: ...
    async def before_stop(self, sandbox: Sandbox) -> None: ...
    async def on_timeout(self, sandbox: Sandbox) -> None: ...

В Python все три можно сделать optional через отдельный класс с пустыми методами или через проверку hasattr. Проще — dataclass с no-op по умолчанию:

@dataclass
class EmptyLifecycle:
    async def after_start(self, sandbox: Sandbox) -> None:
        pass

    async def before_stop(self, sandbox: Sandbox) -> None:
        pass

    async def on_timeout(self, sandbox: Sandbox) -> None:
        pass

Шаг 2. Обмотать agent.run

main.py — main()

lifecycle = EmptyLifecycle()

async def main() -> None:
    await lifecycle.after_start(sandbox)
    try:
        result = await agent.run(prompt, deps=sandbox)
        print(result.output)
    finally:
        await lifecycle.before_stop(sandbox)
        await sandbox.stop()

finally важен: агент упал — уборка всё равно. Именно здесь позже проверят uncommitted git и snapshot.

Как выглядел бы cloud lifecycle (иллюстрация)

# lifecycle.py — не обязательно сейчас
class CloudLifecycle:
    async def after_start(self, sandbox: Sandbox) -> None:
        await sandbox.exec('git config user.name "Agent"')
        await sandbox.exec("uv sync")

    async def before_stop(self, sandbox: Sandbox) -> None:
        status = await sandbox.exec("git status --porcelain")
        if status["stdout"].strip():
            await sandbox.exec('git add -A && git commit -m "WIP: auto-save"')
        if hasattr(sandbox, "snapshot"):
            await sandbox.snapshot()
LOCAL = ПУСТЫЕ HOOKS На ноутбуке git config уже есть, uv sync вы делали сами. Структура нужна, чтобы cloud не был «другой программой».

Проверьте

Временно замените lifecycle на логирующий:

@dataclass
class LoggingLifecycle:
    async def after_start(self, sandbox: Sandbox) -> None:
        print(f"[lifecycle] after_start: {sandbox.type}", file=sys.stderr)

    async def before_stop(self, sandbox: Sandbox) -> None:
        print(f"[lifecycle] before_stop: {sandbox.type}", file=sys.stderr)

    async def on_timeout(self, sandbox: Sandbox) -> None:
        pass

lifecycle = LoggingLifecycle()
uv run python main.py . "Прочитай pyproject.toml"

В stderr две строки bracketing ответа агента. Верните EmptyLifecycle — снова тишина, поведение то же.

Готово, если:

  • Есть тип/класс lifecycle с after_start / before_stop
  • after_start вызывается до agent.run
  • before_stop и sandbox.stop() — в finally
  • С пустым lifecycle агент ведёт себя как в 4.3
  • С logging lifecycle виден порядок вызовов
ДАЛЬШЕ — МОДУЛЬ 5 Sandbox abstraction закрыта. Следующая боль: контекст растёт с каждым tool call. Pruning и лимиты вывода — там.