Точная маршрутизация каждой команды: Практическая реализация поддержки нескольких навыков в HagiCode Preset Task
Точная маршрутизация каждой команды: Практическая реализация поддержки нескольких навыков в HagiCode Preset Task
Один preset содержит несколько команд, но может использовать только одни общие требования к навыкам? Это изменение позволяет каждой команде независимо объявлять, какой навык она использует, и визуализировать эту привязку на панели — значки, сводки, установка одним щелчком, всё в одном потоке.
Предпосылки
Сначала немного предпосылок.
Система preset task в HagiCode — это подключаемая система небольших инструментов. Пользователям не нужно вводить команды вручную, достаточно заполнить несколько полей на визуальной панели, нажать кнопку, и можно создать сеанс автоматической задачи. Каждый preset по сути является каталогом, обычно он выглядит так:
manifest.json: информация о идентичности presetpanel.json: определение формы визуальной панелиcommands.json: список команд для фактического выполненияtask-preset.jsonилиprompts.json: параметры задачи и требования к навыкам
Эта система действительно удобна в использовании, но мы быстро столкнулись с неудобным местом.
В ранних версиях навыки могли объявляться только в массиве requirements на уровне preset. Что это значит? Все команды в одном preset должны были использовать одни и те же требования к навыкам. На первый взгляд это нормально, но на практике получаются такие сценарии:
В одном preset пять команд: первая должна использовать навык last30days, третья — ui-master, а остальные три не требуют никаких навыков. В старом дизайне это невозможно. Чтобы маршрутизировать разные команды на разные навыки, пришлось бы принудительно разбить эти команды на несколько preset, и конфигурация бы раздулась.
Именно это решает предложение extend-preset-task-multiple-skills-support: дать каждой команде возможность независимо объявлять, какой навык она использует, и визуализировать эту привязку в UI.
О HagiCode
Решение, которым мы делимся в этой статье, основано на нашем практическом опыте в проекте HagiCode. HagiCode — это проект AI-помощника по коду, а система preset task — это быстрый интерфейс пользователя для операций. Каждое изменение, о котором мы поговорим ниже, было получено путём реальной отладки и оптимизации —毕竟纸上得来终觉浅. Исходный код проекта на HagiCode-org/site, заинтересованные могут сразу поставить звёздочку.
Сначала чётко сформулируйте проблему: почему не таблица сопоставления
Прежде чем приступить к работе, первое, что приходит на ум: создать таблицу сопоставления commandSkillMappings, чтобы отдельно хранить отношение “ID команды → skill”. Звучит чисто, разделение ответственности嘛.
Но если внимательно подумать, обнаруживается, что это неправильно.
Каждая команда в commands.json уже имеет ID, и в таблице сопоставления этот ID придётся копировать снова. Два файла, один и тот же ID — если кто-то изменит команду и забудет синхронизировать таблицу сопоставления, данные разойдутся. Такой дизайн “разделения ради разделения” в долгосрочной перспективе затраты на поддержку будут намного больше, чем та чистота, которую он приносит. В итоге это просто лишние проблемы.
Поэтому мы выбрали более прямой путь: добавить необязательное поле skill непосредственно в определение команды. Команда сама объявляет, какой skill она использует, поддерживается близко, и ничто не потеряется.
За этим решением стоит ещё один важный принцип дизайна, который стоит выделить отдельно.
Основа первая: разделение ответственности на двух уровнях данных
Это самое важное понимание в этой модификации.
У многих первая реакция: раз в команде есть skill, то при проверке requirements (контроль доступа по навыкам) не нужно ли сканировать поле skill каждой команды?
Нет.
Мы намеренно разделили это на два уровня:
- Поле
skillвcommands.json: отвечает только за объявление привязки. Оно говорит системе “к какому skill должна быть привязана эта команда”, используется для визуализации prompt-преамбулы и отображения в UI. - Массив
requirementsвtask-preset.json: это авторитетное перечисление. Это настоящий контроль доступа, определяющий, какие навыки должен иметь preset для запуска.
Иными словами, skill отвечает на вопрос “к какому привязать, что визуализировать”, а requirements отвечает на вопрос “разрешено ли запускать”. Две разные вещи, не смешивайте их.
Преимущество такого разделения в том, что логика проверки естественным образом проста. Поскольку контроль доступа всегда основан на requirements на уровне preset и выполняется дедупликация по CacheKey, несколько команд, привязанных к одному skill, будут проверены только один раз, без повторного доступа. Уровень команды skill не вводит дополнительных накладных расходов на обнаружение.
Этот принцип также является фундаментальной причиной, по которой мы отвергли решение с таблицей сопоставления — таблица сопоставления создаст ложное впечатление, что “привязка равна контролю доступа”, и снова смешает две ответственности. Хитрость превратилась в ошибку.
Основа вторая: как выглядит определение команды
Модифицированное определение команды добавляет необязательное поле skill к исходной основе. В качестве примера возьмём bundled preset last30days, его commands.json примерно выглядит так:
{ "$schema": "../../schemas/commands.schema.json", "version": "1.1", "commands": [ { "id": "research", "skill": "last30days", "prompt": "调研一下最近30天大家对 {topic} 的真实讨论" }, { "id": "summarize", "prompt": "把上面的调研结果整理成一份摘要" } ]}Несколько важных моментов:
versionобновлена до1.1, соответствующая схема тоже добавила необязательное полеskill.- Первая команда
researchпривязана к skilllast30days, при выполнении будет маршрутизирована на этот навык. - Вторая команда
summarizeне привязана к skill, это обычная команда, идёт по пути по умолчанию. - Обратите внимание, здесь нет никакого requirement в команде. Настоящий контроль доступа — в
requirementsвtask-preset.json:
{ "requirements": [ { "key": "last30days", "cacheKey": "skill:last30days" } ]}Привязанный к команде research last30days должен появиться в этом requirements, иначе возникнет проблема — именно это жёсткое ограничение обсуждается в следующем разделе. Насильно не хорошо.
Основа третья: перекрёстная проверка при загрузке
Просто объявить привязку в данных недостаточно, нужен кто-то, чтобы гарантировать, что “команда привязана к skill, но в requirements вообще не объявлено” — такие сиротские привязки не пройдут в продакшен.
Эта гарантия — ValidateCommandSkills. Он выполняется при загрузке пакета preset, проверяя для каждой команды, что её skill можно найти в requirements на уровне preset. Если не находит, считает пакет незаконным, напрямую отключает весь preset и выбрасывает диагностический код command-skill-not-in-requirements.
Почему отключить весь пакет, а не просто пропустить эту команду? Потому что preset — это целое, команды часто имеют зависимости (вывод одной команды подаётся следующей). Если тихо пропустить одну, следующая команда получит пустой ввод, поведение будет совершенно непредсказуемым. Люди непредсказуемы, код тоже. Лучше пользователь увидит явную ошибку, чем задача уйдёт не туда в середине. На это нельзя мелочиться.
Эта проверка выполняется при загрузке, то есть проблема будет обнаружена в момент регистрации preset, а не когда пользователь нажимает “запустить”. Для пользовательского опыта ранняя ошибка всегда лучше поздней.
Основа четвёртая: идемпотентное соединение преамбулы prompt
Далее, самый тонкий момент в цепочке выполнения.
Когда команда привязана к skill, например last30days, перед фактическим выполнением система должна “соединить” информацию об этом skill перед командой, сформировав полную однострочную инструкцию для исполнителя. Этот процесс выполняется CombineCommandSkillPrelude.
Приведём конкретный пример. Prompt команды research — “调研一下最近30天大家对 {topic} 的真实讨论”, привязанный skill — last30days, тогда финальная инструкция для исполнителя примерно такая:
/last30days 调研一下最近30天大家对 {topic} 的真实讨论То есть перед prompt добавляется преамбула /last30days. Исполнитель, видя эту преамбулу, понимает, что сначала нужно переключить контекст на skill last30days.
Здесь есть легко наступаемая яма: идемпотентность.
Почему нужно подчёркивать идемпотентность? Потому что в некоторых сценариях prompt может уже содержать эту преамбулу skill (например, пользователь вручную написал половину, или скопировал откуда-то). Если система глупо соединит ещё раз, получится /last30days /last30days 调研..., исполнитель либо выдаст ошибку, либо поведение будет аномальным.
Поэтому CombineCommandSkillPrelude перед соединением сначала проверяет, если префикс уже существует, не добавляет снова. Этот шаг кажется незначительным, но может заблокировать очень скрытую категорию багов.
Стоит упомянуть, что вся логика инъекции преамбулы выполняется на уровне определения preset (BuildCommandPrelude в PresetTaskCatalogProvider), код создания сеанса в SessionsController вообще не нужно трогать. Это также преимущество разделения ответственности — вход выполнения остаётся стабильным, сложность маршрутизации навыков собрана внутри уровня определения.
Основа пятая: как фронтенд отображает привязку
Бэкенд разгладил модель данных и цепочку выполнения, последний шаг — позволить пользователям “видеть” эту привязку в интерфейсе. Ведь если функционал пользователь не чувствует, то почти что его нет.
На фронтенде сделано три вещи.
Первое, добавить значки в селектор команд. В command-picker рядом с каждой привязанной к skill командой отображается маленький значок, указывающий, какой skill она использует. Пользователь сразу видит, какая команда “с навыком”, какая обычная.
Второе, блок сводки requirement-check. На панели есть отдельная область сводки, перечисляющая все требования к skill, которые должен удовлетворять текущий preset, а также какой skill привязан к каждой команде. Данные для этого блока берутся из отображения commandSkillsByRequirementKey — команды группируются по привязанному requirement key, удобно пользователю сразу сопоставить “требования” и “фактическую привязку”. Не так ли, “нарисовал тигра, получилось похоже на собаку” — поэтому логика агрегации должна быть прямой, не цветистой.
Третье, глубокая ссылка для установки одним щелчком при неудаче. Если requirement check обнаруживает, что какой-то skill не установлен, пользователю не нужно самому искать документацию и вход установки. Интерфейс напрямую даёт кнопку глубокой ссылки, нажав которую можно перейти к соответствующему процессу установки. Этот шаг сжимает расстояние между “обнаружением проблемы” и “решением проблемы” до минимума.
Типы на фронтенде тоже сдержаны, тип команды просто добавил skill?: string, и сделал нормализацию (|| undefined), чтобы пустые строки не создавали проблем в последующих суждениях.
Практика: пять шагов для полной модификации
Связав разрозненные точки, вся модификация — это пять шагов:
- Расширить схему:
commands.schema.jsonдобавить необязательное полеskill, номер версии поднялся до1.1. - Парсинг + проверка:
NormalizeCommandsотвечает за парсинг определений команд,ValidateCommandSkillsделает перекрёстную проверку, skill команды должен быть найден в requirements на уровне preset. - Инъекция преамбулы:
BuildCommandPreludeперед выполнением идемпотентно соединяет преамбулу/skillперед командой, не нужно менятьSessionsController. - Миграция bundled preset:
last30daysиui-master这两个内置 preset 的commands.json改一下,给相应命令补上skill字段。迁移只动 commands.json,不碰其他文件。 - Визуализация на фронтенде: типы добавить поля, command-picker добавить значки, requirement-check добавить блок сводки, при неудаче дать глубокую ссылку для установки одним щелчком.
Несколько практических замечаний, перечислю отдельно:
- Одна команда может привязать только один skill. Это текущее ограничение. Если сценарий действительно требует одну команду для запуска нескольких навыков, аварийный выход — объявить несколько skill в
requirementsна уровне preset, чтобы они сосуществовали на уровне preset. - Диагностический код при неудаче проверки —
command-skill-not-in-requirements, при устранении проблем просто ищите этот код. - Нормализация на фронтенде помните
|| undefined, не позволяйте пустым строкам смешиваться в логику суждения. - При миграции трогаем только commands.json, со стороны requirements остаётся без изменения, избегая случайных изменений.
- Тестирование на бэкенде охватывает три сценария: skill команды в requirements (прошёл), не в (пакет отключён), несколько команд привязаны к одному skill (дедупликация нормальная).
Итог
Эта модификация поддержки нескольких навыков в preset task表面上只是给命令加了 skill 字段,но背后引出的是一个值得 задуматься的设计问题: привязка и контроль доступа, стоит ли их разделять?
Наш ответ — стоит. Поле skill только “к какому привязать, что визуализировать”, а requirements уже “разрешено ли запускать”. Если эти две ответственности смешаются, будь то таблица сопоставления или другая форма, последующая проверка, дедупликация, отображение в UI станут неудобными. После разделения каждый уровень стал проще: контроль доступа всегда основан на одном авторитетном перечислении, привязка поддерживается близко, не расходится, соединение преамбулы идемпотентно и контролируемо, UI просто отображает уже чёткие данные.
Оглядываясь назад, вся модификация не использовала никакой изощрённой техники, полагаясь только на чёткое разделение ответственности и затем полное покрытие каждого уровня, где нужно гарантировать. После этой полировки система preset task в HagiCode наконец-то может точно маршрутизировать каждую команду на нужный skill. В конце концов, всё должно быть так просто……
Ссылки
- HagiCode-org/site: исходный код проекта, полная реализация системы preset task здесь.
- Официальный сайт HagiCode: узнайте о полных возможностях HagiCode.
- Предложение OpenSpec
extend-preset-task-multiple-skills-support: оригинальный проектный документ этой модификации, включая proposal, design и tasks.
开始使用 HagiCode
一次安装,几分钟上手
HagiCode for Windows 在 Microsoft Store 免费提供。打开商店即可安装并保持更新;也可以先对比各版本与定价,再决定从哪个渠道开始。