Четыре протокола и одна кнопка «Разрешить»: как я подключил Claude Code, Codex, Qwen и Hermes к Telegram

в 11:16, , рубрики: agent client protocol, ai-агенты, claude code, codex, json-rpc, open source, qwen code, Rust

Claude Code и Codex умеют работать долго и без присмотра, но только пока ты сидишь рядом. Агент то спрашивает разрешение на команду, то задаёт уточняющий вопрос и ждёт ответа. Отошёл от компьютера - и всё встало.

Как-то мне пришлось срочно уехать по делам, а задач на день было много. Хотелось ставить их агентам с телефона и подтверждать команды кнопкой, а не держать открытыми шесть терминалов. Так появился agent-hub: десктопное приложение на Rust, в котором каждая тема Telegram-группы - отдельная сессия агента на моём компьютере. За 3-4 часа в дороге я провёл через него больше шести задач.

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

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

Что должен уметь хаб

Если посмотреть на работу с агентом со стороны пользователя, взаимодействий немного:

- агент пишет текст;
- агент вызывает инструмент (команду, правку файла, поиск);
- агент просит разрешение на вызов;
- агент задаёт вопрос с вариантами ответа;
- агент присылает файл;
- ход закончился или упал с ошибкой.

В обратную сторону идут сообщения пользователя, ответы на разрешения и вопросы, файлы и команда остановки. Всё это нужно уметь показать в теме Telegram и вернуть агенту. Проблема в том, что каждый агент говорит об этом на своём языке.

Четыре протокола

Все четыре агента запускаются как дочерние процессы и общаются через stdin и stdout, но на этом сходство заканчивается.

Агент

Запуск

Протокол

Claude Code

claude -p --input-format stream-json --output-format stream-json

JSON-строки плюс управляющие запросы control_request

Codex

codex app-server

JSON-RPC: треды, ходы, элементы

Qwen Code

qwen --acp

Agent Client Protocol

Hermes

hermes acp

тоже ACP

Лучше всего разницу видно на одном и том же событии - запросе разрешения.

Claude Code присылает управляющий запрос и ждёт ответа с тем же request_id:

{"type": "control_request", "request_id": "c76cce43-…",
 "request": {"subtype": "can_use_tool", "tool_name": "Write",
             "input": {"file_path": "/work/note.txt", "content": "hi"}}}

Ответ хаба:

"type": "control_response",
 "response": {"subtype": "success", "request_id": "c76cce43-…",
              "response": {"behavior": "allow",
                           "updatedInput": {"file_path": "/work/note.txt", "content": "hi"}}}}

При отказе вместо allow уходит {"behavior": "deny", "message": "…"}, и текст причины агент видит как результат инструмента.

Codex - это JSON-RPC, и для команд и правок файлов у него разные методы: item/commandExecution/requestApproval и item/fileChange/requestApproval. Из параметров хаб берёт command или reason, а отвечает коротко:

{"decision": "accept"}

или

{"decision": "decline"}

Qwen работает по ACP. Запрос session/request_permission приходит со списком вариантов, и клиент должен выбрать один из них:

{"sessionId": "s-1",
 "toolCall": {"title": "rm -rf build", "kind": "execute",
              "rawInput": {"command": "rm -rf build"}},
 "options": [
   {"optionId": "proceed_always_project", "kind": "allow_always"},
   {"optionId": "proceed_once", "kind": "allow_once"},
   {"optionId": "cancel", "kind": "reject_once"}]}
{"outcome": {"outcome": "selected", "optionId": "proceed_once"}}

Здесь я сознательно никогда не выбираю allow_always: с телефона легко нажать не туда, поэтому каждый вызов снова идёт к человеку. У Qwen есть и неочевидная особенность: свои уточняющие вопросы он присылает тем же session/request_permission, только с пометкой в _meta. Хабу приходится смотреть внутрь, чтобы понять, разрешение это или вопрос.

Общий слой

Чтобы Telegram не знал про протоколы, а агенты не знали про Telegram, между ними стоит небольшой трейт. Это «человек на той стороне»:

/// Boxed futures keep the trait object-safe, so the backend does not depend on the messenger.
pub trait UserChannel: Send + Sync {
    fn request(&self, tool: ToolRequest) -> BoxFuture<'_, Decision>;
    fn ask(&self, questions: Vec<Question>) -> BoxFuture<'_, QuestionsOutcome>;
    fn send_file(&self, file: OutgoingFile) -> BoxFuture<'_, FileDelivery>;
}

Каждый агент переводит свой запрос в ToolRequest, спрашивает канал и переводит Decision обратно в формат своего протокола. Для Codex это выглядит так:

match method {
    "item/commandExecution/requestApproval" => {
        Ok(approve(channel, request(SHELL, &command_summary(&params))).await)
    }
    "item/fileChange/requestApproval" => {
        Ok(approve(channel, request(PATCH, &file_change_summary(&params))).await)
    }
    "item/tool/call" => Ok(tool_response(call_tool(channel, &params).await?)),
    "item/tool/requestUserInput" => user_input(channel, &params).await,
    other => Err(RequestError::Unsupported(other.to_owned())),
}

Всё остальное, что агент сообщает сам, без ответа, сводится к одному перечислению:

pub enum AgentEvent {
    SessionStarted(SessionId),
    AssistantText(String),
    ToolCall(ToolUse),
    Finished(Finished),
    Failed(String),
    BackgroundAbandoned(Vec<String>),
    Quota(QuotaUpdate),
}

Код разбит на крейты: hub-core без ввода-вывода (команды, пути, отрисовка), hub-agent с общими типами и JSON-RPC, по крейту на каждого агента, hub-telegram на teloxide и hub-app с окном на egui. Telegram-крейт ничего не знает про control_request или ACP, а крейт агента - про кнопки. Чтобы добавить нового агента, нужен только новый крейт, который реализует перевод.

Как запрос превращается в кнопку и обратно

Агент ждёт ответа внутри своего протокола, а пользователь нажмёт кнопку когда-нибудь потом, в другом обработчике. Связать их помогает реестр ожидающих запросов и oneshot-канал:

async fn approval(&self, tool: ToolRequest) -> Decision {
    let id = ApprovalId::from_random(random_id());
    let (responder, decision) = oneshot::channel();
    lock(&self.registries).approvals.open(id.clone(), responder);
    // ... отправляем сообщение с кнопками
    match tokio::time::timeout(self.timeout, decision).await {
        Ok(Ok(decision)) => decision,
        Ok(Err(_)) => Decision::Denied(/* канал закрыт /),
        Err(_) => {
            // правим сообщение: «⌛ Нет ответа - запрещено»
            Decision::Denied(/ тайм-аут */)
        }
    }
}

У кнопок Telegram в callback_data помещается не больше 64 байт, поэтому туда кладётся только короткая строка вида ap:allow:<id> со случайным идентификатором. Обработчик нажатия разбирает её, находит в реестре нужный oneshot и отправляет в него решение. Агент в это время просто ждёт свой Decision.

Если никто не ответил за отведённое время (по умолчанию 10 минут), вызов запрещается, а сообщение с кнопками правится, чтобы было видно, что ответа не было. Агент получает отказ и продолжает работу с его учётом, а не висит бесконечно.

Ограничения Telegram

Агенты пишут Markdown, а Telegram понимает только небольшое подмножество HTML: жирный, курсив, код, ссылки, <pre> и <blockquote>. Заголовков, списков и таблиц нет. Поэтому ответ агента разбирается через pulldown-cmark и пересобирается: заголовки становятся жирным текстом, пункты списков - строками с маркерами, а таблицы - строками «ключ: значение». Широкие таблицы на телефоне читаются плохо, их строки превращаются в карточки: первая ячейка - заголовок, остальные - подписанные поля.

Сообщение в Telegram ограничено 4096 символами. Длинный ответ режется на части по границам строк, причём уже после экранирования: нельзя разрезать &amp; пополам, иначе Telegram отклонит всё сообщение.

Даже после этого Telegram иногда отказывается разбирать разметку. Терять ответ агента из-за ошибки форматирования нельзя, поэтому в таком случае сообщение отправляется ещё раз обычным текстом. На ограничение частоты (RetryAfter) хаб делает одну повторную попытку, но ждёт не больше минуты, чтобы агент не простаивал из-за Telegram.

Перенос сессий

Отдельная история - команды /save и /restore, которые сохраняют сессию и продолжают её в другой теме. Казалось бы, достаточно запомнить ID сессии, но историю каждый агент хранит по-своему: у Claude это projects/<проект>/<id>.jsonl, у Codex - файлы rollout-…-<id> в sessions/, у Qwen и Hermes свои форматы. Поэтому у каждого агента своя реализация экспорта и импорта за общим трейтом SessionArchive. В снимок попадает только история самого агента, без файлов проекта и без данных входа.

Безопасность

Хаб запускает агентов на моём компьютере от моего пользователя, поэтому о безопасности пришлось думать с самого начала.

- Стороннего сервера нет: приложение само ходит в Bot API и запускает CLI локально.
- Бот принимает сообщения только из одной группы и только от пользователей из списка, остальное отклоняется и пишется в лог.
- Что спрашивать, а что нет, решают режим разрешений и настройки самого агента. «Разрешить всегда» хаб не выбирает.
- Рабочую директорию нельзя увести за пределы заданного корня, в том числе через симлинки. Файлы агент может прислать только из своей директории.
- API-ключи хранятся в системном хранилище ключей, а не в файле настроек. Токены входа агентов хаб не читает.

Главный риск остаётся: доступ к Telegram-аккаунту означает доступ к агентам. Так что без двухфакторной защиты в Telegram таким инструментом лучше не пользоваться.

Почему Rust

Один бинарник без рантайма для Windows, macOS и Linux, предсказуемое поведение процесса, который работает сутками, и строгие проверки компилятора и clippy. Сейчас в проекте около 33 тысяч строк, сборки для всех платформ собираются в GitHub Actions.

Итого

Самым полезным решением оказался узкий трейт между агентами и мессенджером. Протоколы у агентов разные и меняются, но всё, что нужно от человека, укладывается в три вопроса: можно ли выполнить, что ответить и куда отправить файл.

Код открыт под MIT: github.com/aprazdnikov/agent-hub-rs. В планах английская версия и поддержка других агентов.

А как вы работаете с агентами, когда отходите от компьютера?

Автор: aprazdnikov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js