Перейти к содержимому

Codex

Обновление образов

Готовые образы агентов обновляются каждое утро и содержат актуальную версию CLI. Чтобы использовать фиксированную версию образа, закрепите версию шаблона.

Codex — агент OpenAI, который работает с файлами, выполняет команды и использует инструменты для решения задач. В AgentBox он запускается в отдельной песочнице: приложение передаёт задание и данные, получает события и забирает результат. Готовый шаблон codex уже содержит CLI агента.

Подключение

Установите SDK AgentBox и задайте AGENTBOX_API_KEY в окружении приложения. Для Codex нужен отдельный OPENAI_API_KEY с доступом к модели из примера, gpt-5.6-luna. При создании песочницы пример передаёт его в CODEX_API_KEY, которую читает codex exec. Модель можно заменить на доступную вашему проекту OpenAI.

Примеры подключают прокси AgentBox socks5h://sandbox-proxy.agentbox.ru:65180 через ALL_PROXY внутри песочницы. Это общая настройка процесса, а не отдельный прокси только для модели. Не записывайте ключ OpenAI в образ или входные файлы.

Отчёт по продажам

Пример загружает три заказа и просит агента подготовить report.md. В выручку входят только оплаченные заказы: 120 000 ₽ онлайн и 80 000 ₽ в рознице. Отменённые 45 000 ₽ должны быть показаны отдельно. Вместо этих строк можно передать выгрузку из CRM, сохранив названия столбцов и правила расчёта.

mjs
import { Sandbox } from "@abox-dev/sdk";

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
const proxy =
  process.env.SANDBOX_PROXY_URL ?? "socks5h://sandbox-proxy.agentbox.ru:65180";

const sandbox = await Sandbox.create("codex", {
  timeoutMs: 600_000,
  envs: {
    CODEX_API_KEY: apiKey,
    ALL_PROXY: proxy,
    NO_PROXY: "localhost,127.0.0.1",
  },
});
try {
  await sandbox.files.makeDir("/home/user/work");
  await sandbox.files.write(
    "/home/user/work/sales.csv",
    "channel,status,amount_rub\nOnline,paid,120000\nRetail,paid,80000\nOnline,cancelled,45000\n",
  );
  await sandbox.commands.run(
    "codex exec --ephemeral --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --model gpt-5.6-luna 'Read sales.csv. Calculate paid revenue by channel and total; exclude cancelled orders. Write report.md: a short sales briefing with a table, the cancelled amount separately, and one next action.'",
    {
      cwd: "/home/user/work",
      timeoutMs: 300_000,
      onStderr: (chunk) => process.stderr.write(chunk),
    },
  );
  console.log(
    "report.md\n" + (await sandbox.files.read("/home/user/work/report.md")),
  );
} finally {
  await sandbox.kill();
}

codex exec выполняет одно задание без терминального интерфейса. --skip-git-repo-check позволяет работать с выгрузкой без Git-репозитория. --ephemeral подходит для одноразового отчёта: история диалога не сохраняется. Файл читается через SDK до удаления песочницы.

Здесь агенту разрешено выполнять команды без подтверждений (--dangerously-bypass-approvals-and-sandbox): границей изоляции служит песочница AgentBox. Агент может изменять её файлы и обращаться в сеть. Выдавайте ему только данные и доступы этой задачи.

Структурированный результат для приложения

Если результат должен попасть в карточку отчёта или следующий шаг процесса, задайте JSON Schema. Следующий пример возвращает валюту, оплаченную выручку, сумму отмен и рекомендацию. --output-last-message сохраняет финальный ответ отдельно от служебного вывода CLI.

mjs
import { Sandbox } from "@abox-dev/sdk";

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
const proxy =
  process.env.SANDBOX_PROXY_URL ?? "socks5h://sandbox-proxy.agentbox.ru:65180";

const sandbox = await Sandbox.create("codex", {
  timeoutMs: 600_000,
  envs: {
    CODEX_API_KEY: apiKey,
    ALL_PROXY: proxy,
    NO_PROXY: "localhost,127.0.0.1",
  },
});
try {
  await sandbox.files.makeDir("/home/user/work");
  await sandbox.files.write(
    "/home/user/work/sales.csv",
    "channel,status,amount_rub\nOnline,paid,120000\nRetail,paid,80000\nOnline,cancelled,45000\n",
  );
  await sandbox.files.write(
    "/home/user/work/schema.json",
    '{"type": "object", "properties": {"currency": {"type": "string", "enum": ["RUB"]}, "paid_revenue": {"type": "integer"}, "cancelled_amount": {"type": "integer"}, "recommendation": {"type": "string"}}, "required": ["currency", "paid_revenue", "cancelled_amount", "recommendation"], "additionalProperties": false}',
  );
  await sandbox.commands.run(
    "codex exec --ephemeral --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --model gpt-5.6-luna --output-schema schema.json --output-last-message summary.json 'Read sales.csv. Calculate paid revenue and cancelled amount separately. Return a sales summary matching the schema, with one concise recommendation.'",
    { cwd: "/home/user/work", timeoutMs: 300_000 },
  );
  const summary = JSON.parse(
    await sandbox.files.read("/home/user/work/summary.json"),
  );
  console.log(JSON.stringify(summary));
} finally {
  await sandbox.kill();
}

Для этих входных данных ожидаются paid_revenue: 200000 и cancelled_amount: 45000. JSON Schema задаёт поля и типы данных результата.

Поток событий и следующий вопрос

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

СобытиеДействие приложения
thread.startedСохранить thread_id для продолжения
item.started с command_executionПоказать, что агент запустил команду
item.completed с agent_messageПоказать готовое сообщение
turn.completedОтметить завершение задания и сохранить расход токенов
turn.failed или errorПоказать ошибку. Не выдавать частичный отчёт за готовый

Пример сначала собирает отчёт, затем в той же песочнице и той же сессии просит добавить два действия для руководителя продаж. Для этого используется codex exec resume с сохранённым ID. --ephemeral здесь не нужен.

mjs
import { Sandbox } from "@abox-dev/sdk";

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
const proxy =
  process.env.SANDBOX_PROXY_URL ?? "socks5h://sandbox-proxy.agentbox.ru:65180";

const sandbox = await Sandbox.create("codex", {
  timeoutMs: 600_000,
  envs: {
    CODEX_API_KEY: apiKey,
    ALL_PROXY: proxy,
    NO_PROXY: "localhost,127.0.0.1",
  },
});
try {
  await sandbox.files.makeDir("/home/user/work");
  await sandbox.files.write(
    "/home/user/work/sales.csv",
    "channel,status,amount_rub\nOnline,paid,120000\nRetail,paid,80000\nOnline,cancelled,45000\n",
  );
  let pending = "";
  let threadId;
  let failure;
  let completed = false;
  function event(message) {
    if (message.type === "thread.started") threadId = message.thread_id;
    if (
      message.type === "item.completed" &&
      message.item.type === "agent_message"
    ) {
      console.log(message.item.text);
    }
    if (
      message.type === "item.started" &&
      message.item.type === "command_execution"
    ) {
      console.log("Command:", message.item.command);
    }
    if (message.type === "turn.completed") {
      completed = true;
      console.log("Usage:", message.usage);
    }
    if (message.type === "turn.failed" || message.type === "error") {
      failure = new Error(
        message.error?.message ?? message.message ?? "Codex failed",
      );
    }
  }
  function onStdout(chunk) {
    pending += chunk;
    let newline;
    while ((newline = pending.indexOf("\n")) !== -1) {
      const line = pending.slice(0, newline);
      pending = pending.slice(newline + 1);
      if (line.trim()) event(JSON.parse(line));
    }
  }
  async function execute(command) {
    completed = false;
    await sandbox.commands.run(command, {
      cwd: "/home/user/work",
      timeoutMs: 300_000,
      onStdout,
      onStderr: (chunk) => process.stderr.write(chunk),
    });
    if (pending.trim()) event(JSON.parse(pending));
    pending = "";
    if (failure) throw failure;
    if (!completed) throw new Error("Codex ended without turn.completed");
  }
  await execute(
    "codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --model gpt-5.6-luna --json 'Read sales.csv. Write report.md with paid revenue by channel, total, and cancelled amount separately.'",
  );
  if (!threadId) throw new Error("Codex did not return a thread ID");
  await execute(
    "codex exec resume --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --json " +
      "'" +
      threadId.replaceAll("'", "'\"'\"'") +
      "' " +
      "'Add two concrete next actions for the sales manager to report.md. Keep the original figures.'",
  );
  console.log(
    "report.md\n" + (await sandbox.files.read("/home/user/work/report.md")),
  );
} finally {
  await sandbox.kill();
}

Завершение команды проверяется отдельно от событий. Не считайте любой текст в stdout или успешный запуск процесса признаком готовности: дождитесь turn.completed, обработайте ненулевой exit code и только затем отдавайте файл.

Диалог в приложении через App Server

codex app-server принимает команды приложения через stdin и отправляет ответы и события через stdout. Пример запускает сервер в готовой песочнице, загружает CSV продаж и выводит ответ по мере генерации.

mjs
import { Readable } from "node:stream";
import { Sandbox } from "@abox-dev/sdk";

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("OPENAI_API_KEY is required");
const sandbox = await Sandbox.create("codex", {
  timeoutMs: 600_000,
  envs: {
    ALL_PROXY:
      process.env.SANDBOX_PROXY_URL ??
      "socks5h://sandbox-proxy.agentbox.ru:65180",
    NO_PROXY: "localhost,127.0.0.1",
  },
});
try {
  await sandbox.files.write(
    "/home/user/sales.csv",
    "channel,paid_revenue_rub\nOnline,120000\nRetail,80000\n",
  );
  const messages = new Readable({ objectMode: true, read() {} });
  let buffer = "";
  const server = await sandbox.commands.run("codex app-server", {
    background: true,
    stdin: true,
    timeoutMs: 300_000,
    onStdout(chunk) {
      buffer += chunk;
      let end;
      while ((end = buffer.indexOf("\n")) !== -1) {
        const line = buffer.slice(0, end);
        buffer = buffer.slice(end + 1);
        try {
          if (line.trim()) messages.push(JSON.parse(line));
        } catch (error) {
          messages.destroy(error);
        }
      }
    },
    onStderr: (chunk) => process.stderr.write(chunk),
  });
  server.wait().then(
    () => messages.push(null),
    (error) => messages.destroy(error),
  );
  const incoming = messages[Symbol.asyncIterator]();
  let status;
  async function receive() {
    const { value: message, done } = await incoming.next();
    if (done) throw new Error("App Server closed before completing the task");
    if (message.error) throw new Error(message.error.message);
    if (message.method === "item/agentMessage/delta")
      process.stdout.write(message.params.delta);
    if (message.method === "turn/completed") {
      status = message.params.turn.status;
      if (status !== "completed")
        throw new Error(message.params.turn.error?.message ?? status);
    }
    return message;
  }
  let nextId = 0;
  async function call(method, params) {
    const id = ++nextId;
    await server.sendStdin(JSON.stringify({ id, method, params }) + "\n");
    while (true) {
      const message = await receive();
      if (message.id === id) return message.result;
    }
  }
  await call("initialize", {
    clientInfo: { name: "sales-app", version: "1.0.0" },
  });
  await server.sendStdin(JSON.stringify({ method: "initialized" }) + "\n");
  await call("account/login/start", { type: "apiKey", apiKey });
  const { thread } = await call("thread/start", {
    model: "gpt-5.6-luna",
    cwd: "/home/user",
    approvalPolicy: "never",
    sandbox: "danger-full-access",
  });
  await call("turn/start", {
    threadId: thread.id,
    input: [
      {
        type: "text",
        text: "Read sales.csv. Give a short sales briefing with revenue by channel and the total in RUB.",
      },
    ],
  });
  while (!status) await receive();
  console.log("\nStatus:", status);
} finally {
  await sandbox.kill();
}

call связывает ответ с запросом по id, продолжая принимать уведомления. Обработчик собирает целые JSON-строки из фрагментов stdout, выводит item/agentMessage/delta и проверяет статус в turn/completed. account/login/start передаёт API-ключ через stdin, а не в аргументах команды.

Для следующего вопроса отправьте ещё один turn/start с тем же thread.id, сохранив процесс и песочницу. Пример заканчивается после первого ответа и удаляет песочницу. Остальные методы описаны в протоколе App Server.

Таймауты и сохранение работы

Время жизни песочницы и таймаут команды — разные ограничения. В примерах песочница живёт до десяти минут, а одно задание ограничено пятью минутами. Если команда завершилась по таймауту, остановите её и решите, какие промежуточные файлы сохранить. Блок finally удаляет песочницу и при ошибке.

Для продолжения позже сохраняйте ID песочницы и thread_id, а песочницу ставьте на паузу. После удаления песочницы пропадут и отчёт, и история Codex, если приложение не сохранило их отдельно.