Это вторая часть статьи по Spring AI в Джеймикс. Короткая аннотация первой — на случай, если прошло время или вы её не читали: мы собрали read-only агент внутри Джеймикс-приложения. Пользователь задаёт вопрос на естественном языке; ChatClient из Spring AI крутит agent loop — дёргает @Tool-методы, пока не наберёт достаточно данных для ответа. Каждый tool читает данные через DataManager с явным fetch plan-ом, поэтому почти полностью остаётся внутри рамок системы безопасности Джеймикс и возвращает только нужные модели поля. UI — обычный Джеймикс-вью, без REST-прослойки. Также в первой части мы убедились, что выбор модели — не деталь: модель без надёжного native tool calling ломает всю схему. Если первую часть не читали — начните с неё, код ниже строится как продолжение.
В этой части мы дадим агенту право менять данные. И вот здесь, в отличие от первой половины, начинают всплывать вопросы, которые ни Spring AI, ни большинство туториалов по агентам обычно не поднимают: под каким пользователем выполняется tool, что делать с транзакциями, как аудировать действия, инициированные моделью, и как заставить агента работать с вашей доменной моделью без ручного перечисления сущностей в промпте.Это не косметические изменения, а ровно те решения, что отделяют демо от приложения, которое можно показывать заказчику.Полный исходник всего, что мы здесь обсуждаем, лежит здесь: демо — можно клонировать и сразу запустить.
Что добавляем
К доменной модели прибавляется одна сущность — ReplenishmentRequest. Это заявка на пополнение склада: товар, склад-получатель, количество, статус, автор.
К набору tools добавляются два write-метода:
reserveStock(productId, warehouseId, quantity) - увеличивает StockItem.reserved.
createReplenishmentRequest(productId, warehouseId, quantity, reason) - создает новую заявку на пополнение.
Сценарий: пользователь говорит "There are only eight bags of Ethiopia Yirgacheffe left in Hamburg, reserve twelve and create a replenishment request for twenty". Агент должен сначала найти соответствующие id (это все умения из первой части), потом дёрнуть reserveStock, заметить, что зарезервировать двенадцать при остатке восемь нельзя, и принять решение — либо зарезервировать что есть и оформить заявку на оставшееся, либо вернуть пользователю человеческое объяснение.
Сущность ReplenishmentRequest:
@JmixEntity
@Table(name = "REPLENISHMENT_REQUEST")
@Entity
public class ReplenishmentRequest {
@JmixGeneratedValue
@Column(name = "ID", nullable = false)
@Id
private UUID id;
@JoinColumn(name = "PRODUCT_ID", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@NotNull
private Product product;
@JoinColumn(name = "WAREHOUSE_ID", nullable = false)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@NotNull
private Warehouse warehouse;
@Column(name = "QUANTITY", nullable = false)
@NotNull
private Integer quantity;
@Column(name = "REASON", length = 512)
private String reason;
@Column(name = "STATUS", nullable = false)
@NotNull
private String status;
@CreatedBy
@Column(name = "CREATED_BY")
private String createdBy;
@CreatedDate
@Column(name = "CREATED_DATE")
private OffsetDateTime createdDate;
@Column(name = "INITIATED_BY_AGENT")
private Boolean initiatedByAgent;
}Два технических момента стоит отметить отдельно:
@CreatedBy / @CreatedDate — Джеймикс-аудит проставит автора и время автоматически, пока в SecurityContext есть аутентификация. Что произойдёт, если аутентификации нет (а в фоновом потоке агента её по умолчанию нет) — разберём ниже.
initiatedByAgent — явный технический флаг - "это создал агент". Это не замена createdBy, а дополнение. Аудитор должен видеть и пользователя, который инициировал диалог, и факт того, что сущность пришла не из формы, а из tool-вызова.
Поле
statusхранится в колонке какString, но наружу выставлено через enum ReplenishmentStatus — чтобы остальной код не имел дела с сырыми строками. Это обычный для Джеймикс EnumClass<String>:
public enum ReplenishmentStatus implements EnumClass<String> {
NEW("NEW"),
IN_PROGRESS("IN_PROGRESS"),
DONE("DONE");
private final String id;
ReplenishmentStatus(String id) {
this.id = id;
}
@Override
public String getId() {
return id;
}
@Nullable
public static ReplenishmentStatus fromId(@Nullable String id) {
for (ReplenishmentStatus status : values()) {
if (status.getId().equals(id)) {
return status;
}
}
return null;
}
}Аксессоры сущности конвертируют между хранимым String и enum:
public ReplenishmentStatus getStatus() {
return status == null ? null : ReplenishmentStatus.fromId(status);
}
public void setStatus(ReplenishmentStatus status) {
this.status = status == null ? null : status.getId();
}getId() — стабильное значение, которое хранится в БД; fromId() возвращает null для неизвестного id, поэтому вызывающий код обязан проверять на null. На этот же enum мы обопрёмся ещё раз в разделе про валидацию — там значение приходит не из нашего кода, а от модели.
Write‑tools
@Component
public class WarehouseWriteTools {
private final DataManager dataManager;
static final int MAX_REASON_LENGTH = 512;
static final int MAX_QUANTITY_PER_REQUEST = 1000;
public WarehouseWriteTools(DataManager dataManager) {
this.dataManager = dataManager;
}
@Tool(description =
"Reserve a given quantity of a product at a specific warehouse. " +
"Returns the actually reserved quantity, which may be less than requested if stock is insufficient. " +
"Use only after stock has been checked with getStock.")
@Transactional
public ReserveResult reserveStock(
@ToolParam(description = "product id (UUID)") String productId,
@ToolParam(description = "warehouse id (UUID)") String warehouseId,
@ToolParam(description = "quantity to reserve, positive integer") int quantity) {
if (quantity <= 0) {
return new ReserveResult(0, "Quantity must be positive");
}
UUID pid = UUID.fromString(productId);
UUID wid = UUID.fromString(warehouseId);
StockItem item = dataManager.load(StockItem.class)
.query("select s from StockItem s " +
"where s.product.id = :pid and s.warehouse.id = :wid")
.parameter("pid", pid)
.parameter("wid", wid)
.optional()
.orElse(null);
if (item == null) {
return new ReserveResult(0, "No stock record for this product at this warehouse");
}
int available = item.getQuantity() - item.getReserved();
if (available <= 0) {
return new ReserveResult(0, "No stock available to reserve");
}
int toReserve = Math.min(available, quantity);
item.setReserved(item.getReserved() + toReserve);
dataManager.save(item);
String note = toReserve < quantity
? "Reserved less than requested: only " + toReserve + " available"
: null;
return new ReserveResult(toReserve, note);
}
@Tool(description =
"Create a replenishment request for a product at a specific warehouse. " +
"Use when the user explicitly asks to order or replenish stock.")
@Transactional
public ReplenishmentRequest createReplenishmentRequest(
@ToolParam(description = "product id (UUID)") String productId,
@ToolParam(description = "warehouse id (UUID)") String warehouseId,
@ToolParam(description = "quantity to order, positive integer") int quantity,
@ToolParam(description = "free-form reason from the user, optional") String reason) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
if (quantity > MAX_QUANTITY_PER_REQUEST) {
throw new IllegalArgumentException(
"Quantity exceeds the per-request limit of " + MAX_QUANTITY_PER_REQUEST);
}
String safeReason = reason == null
? null
: reason.substring(0, Math.min(reason.length(), MAX_REASON_LENGTH));
Product product = dataManager.load(Product.class)
.id(UUID.fromString(productId))
.fetchPlan(fp -> fp.addAll("name"))
.one();
Warehouse warehouse = dataManager.load(Warehouse.class)
.id(UUID.fromString(warehouseId))
.fetchPlan(fp -> fp.addAll("name"))
.one();
ReplenishmentRequest req = dataManager.create(ReplenishmentRequest.class);
req.setProduct(product);
req.setWarehouse(warehouse);
req.setQuantity(quantity);
req.setReason(safeReason);
req.setStatus(ReplenishmentStatus.NEW);
req.setInitiatedByAgent(true);
ReplenishmentRequest saved = dataManager.save(req);
return dataManager.load(ReplenishmentRequest.class)
.id(saved.getId())
.fetchPlan(fp -> fp
.addAll("quantity", "reason", "status", "initiatedByAgent")
.add("product", pFp -> pFp.addAll("name"))
.add("warehouse", wFp -> wFp.addAll("name")))
.one();
}
}ReserveResult — вычисляемый результат, не DTO сущность:
public record ReserveResult(int reserved, String note) {}В наборе есть и маленький помощник parseStatusFromLlm для безопасной конверсии присланной моделью строки статуса в enum ReplenishmentStatus. При создании он не нужен (статус там всегда NEW), но это правильное место валидировать статус, пришедший от модели; полный листинг показываем ниже, в разделе про валидацию.
Как выглядит write-прогон
На запрос "Reserve 12 bags of Ethiopia Yirgacheffe in Hamburg, and create a replenishment request for 20 more" — при остатке всего 8 единиц в Hamburg DC — реальный лог tool-вызовов такой:
13:54:36 >>> listWarehouses()
<<< 3 warehouse(s): [Hamburg DC, Rotterdam DC, Antwerp DC]
13:54:55 >>> findProducts(keyword="ethiopia yirgacheffe")
<<< 1 match: [Ethiopia Yirgacheffe 1kg]
13:55:17 >>> getStock(productId="...")
<<< Hamburg DC=8/8, Antwerp DC=5/5
13:55:54 >>> reserveStock(productId="...", warehouseId="...hamburg...", quantity=12)
<<< reserved=8, note="Reserved less than requested: only 8 available"
13:56:23 >>> createReplenishmentRequest(productId="...", warehouseId="...hamburg...",
quantity=20, reason="User requested replenishment")
<<< id=019e5ec7-..., status=NEWДва момента. Модель вызвала getStock перед reserveStock — ровно как требует system prompt. И на partial-reservation — просила 12, доступно было 8 — не запаниковала и не выдумала недостающее: взяла результат reserved=8 и перешла к заявке на пополнение. Логика, которая обрезала 12 до 8, живёт в reserveStock, а не в модели; агент лишь реагирует на возвращённое значение.
Одно честное наблюдение: reason, который передала модель — "User requested replenishment" — пресный. Пользователь причину не назвал, и модель заполнила поле шаблоном вместо чего-то полезного вроде "Shortfall after reservation: requested 12, only 8 available". Это типично для свободно-текстовых параметров tool: модель заполняет их шаблонно, если в description @ToolParam или в system prompt не попросить конкретики. Напоминание, что, как мы сказали в первой части, описание tool влияет не только на то, какой tool вызывается, но и на качество данных, которые он пишет.
Транзакции: где границы
@Tool‑методы написаны с @Transactional. Это сознательный выбор, и стоит обсудить, что он значит на практике.
Сначала техническая деталь, потому что её часто понимают не совсем правильно. Spring AI вызывает tool‑метод через бин из контекста (MethodToolCallback берёт бин из ApplicationContext и вызывает его метод). Это значит, что вызов идёт через Spring‑прокси — и @Transactional срабатывает корректно. Self‑invocation, при котором прокси обходится, бывает только когда один метод того же класса дёргает другой через this. У нас этого нет.
Дальше про границы транзакций. Один пользовательский запрос порождает несколько tool‑вызовов. Каждый tool — своя транзакция. Это означает:
Между вызовами reserveStock и createReplenishmentRequest чужие транзакции могут изменить базу.
Если первый tool прошел успешно, а второй упал — первый не откатится. У вас останется зарезервированный остаток и не будет заявки.
Параллельные сессии могут зарезервировать остаток сверх доступного: между getStock и reserveStock другой пользователь успевает забрать тот же остаток. Лечится PESSIMISTIC_WRITE lock в reserveStock либо проверкой «ожидаемого» значения в момент записи (optimistic‑стиль). В демо мы это не закладываем, в проде — обязательно.
Можно ли обернуть весь agent loop в одну транзакцию? Технически — можно. Практически — не нужно. Длительная транзакция, удерживаемая на время раундов с LLM (это секунды, иногда десятки секунд), — это блокировки, дедлоки и проблемы с пулом. Не делайте так.
Правильный подход — проектировать tools идемпотентными или компенсируемыми:
reserveStock — идемпотентен на повтор с теми же аргументами в пределах разумного.
Если createReplenishmentRequest упал после reserveStock, в системе должен быть способ освободить резерв — либо вручную, либо ночным job'ом по истечению TTL.
То, что выглядит как «транзакционная сложность из‑за агента», на самом деле обычная распределенная транзакционная сложность. Агент ее не порождает, а только подсвечивает.
Безопасность: главный раздел этой статьи
Это та часть, которую в туториалах по Spring AI обычно вообще не обсуждают, потому что они пишутся в отрыве от слоя приложения с правами доступа. В Джеймикс этот слой есть, и игнорировать его при подключении AI-агента — значит обойти безопасность архитектурно.
Проблема: фоновый поток теряет аутентификацию
Вспомним код из первой части:
CompletableFuture.supplyAsync(() ->
warehouseAgentClient.prompt()
.user(question)
.call()
.content()
).whenComplete(...);
CompletableFuture.supplyAsync запускает задачу в ForkJoinPool.commonPool(). У этого пула нет ни вашего SecurityContext, ни Джеймикс‑аутентификации, ни Vaadin UI‑контекста. Это просто рабочий поток.
Когда внутри этого потока tool вызывает dataManager.load(…), и DataManager спрашивает «а кто пользователь?», он получит anonymous. В зависимости от ваших ролей это означает либо отказ в доступе, либо — что хуже — бесшумную выборку данных под анонимом, права которого совсем не такие, как у залогиненного пользователя.
Это нужно решать явно. У Джеймикс есть для этого SystemAuthenticator, у Spring Security — propagation через executor. Давайте рассмотрим оба метода.
Решение 1: пробросить аутентификацию пользователя в поток
Когда мы хотим, чтобы агент действовал от имени пользователя — то есть применял ровно те политики, которые применяются, когда пользователь работает с UI напрямую, нужно явно перенести аутентификацию в фоновый поток.
@Subscribe(id = "askButton", subject = "clickListener")
public void onAskButtonClick(ClickEvent<JmixButton> event) {
String question = questionField.getValue();
if (question == null || question.isBlank()) {
return;
}
// Получаем SecurityContext перед запуском фонового потока.
// DelegatingSecurityContextExecutor передаёт контекст в рабочий поток
// и очищает его автоматически после каждого вызова tool - нет ручных проверок через try/finally.
Executor secureExecutor = new DelegatingSecurityContextExecutor(
ForkJoinPool.commonPool(),
SecurityContextHolder.getContext());
progressBar.setVisible(true);
askButton.setEnabled(false);
UI ui = UI.getCurrent();
CompletableFuture.supplyAsync(() ->
warehouseAgentClient.prompt()
.user(question)
.call()
.content(),
secureExecutor
).whenComplete((answer, ex) -> ui.access(() -> {
progressBar.setVisible(false);
askButton.setEnabled(true);
answerField.setValue(ex != null ? "Error: " + ex.getMessage() : answer);
}));
}Теперь все tool‑вызовы внутри агента видят Authentication пользователя, который нажал кнопку. Row‑level security, soft delete, @CreatedBy — все работает как обычно. С точки зрения Джеймикс агент — это просто «длинный сценарий пользователя», в котором клиент не браузер, а LLM.
Это поведение по умолчанию правильно для большинства сценариев. Если пользователь не имеет права создавать заявки на пополнение, агент тоже не должен этого делать от его имени.
DelegatingSecurityContextExecutor — стандартный механизм SpringSecurity для распространения SecurityContext в фоновые потоки. Он захватывает контекст в момент создания executor-а (то есть в UI‑потоке, где аутентификация точно есть) и восстанавливает его перед каждой задачей, автоматически очищая после. Ручного try/finally с SecurityContextHolder.clearContext() не нужно — и забыть его уже нельзя.
ForkJoinPool.commonPool() переиспользует потоки между задачами, так что автоматическая очистка здесь принципиальна: без неё следующая задача в том же потоке могла бы увидеть чужого пользователя.
Решение 2: выполнить tool под системным пользователем
Иногда нужно обратное: tool должен выполниться с правами шире, чем у пользователя. Типичные сценарии:
Запись в аудит‑таблицу, к которой обычные пользователи доступа не имеют.
Чтение из служебного справочника, права на который раздавать нерационально.
Создание технических артефактов (например, лога диалога), которые не должны зависеть от прав пользователя.
Для таких случаев в Джеймикс есть SystemAuthenticator:
@Component
public class AgentAuditTools {
private final DataManager dataManager;
private final SystemAuthenticator systemAuthenticator;
private final CurrentAuthentication currentAuthentication;
public AgentAuditTools(DataManager dataManager,
SystemAuthenticator systemAuthenticator,
CurrentAuthentication currentAuthentication) {
this.dataManager = dataManager;
this.systemAuthenticator = systemAuthenticator;
this.currentAuthentication = currentAuthentication;
}
public void logAgentAction(String toolName, String arguments, String result) {
String initiator = currentAuthentication.getUser().getUsername();
systemAuthenticator.runWithSystem(() -> {
AgentActionLog entry = dataManager.create(AgentActionLog.class);
entry.setToolName(toolName);
entry.setArguments(arguments);
entry.setResult(result);
entry.setInitiator(initiator);
entry.setLoggedAt(OffsetDateTime.now());
dataManager.save(entry);
});
}
}Ключевое:
Имя инициатора (initiator) считывается до переключения в системного пользователя. После переключения currentAuthentication.getUser() вернет уже системного (system), и аудит будет бесполезен.
runWithSystem гарантирует, что в try‑блоке выполняется вызов с правами системы, и после блока контекст восстанавливается, даже если внутри было исключение.
logAgentAction не помечен @Tool — это служебный метод, который вы дергаете из других tools или из обертки над агентом. Модель не должна знать о его существовании.
Что использовать в каждом конкретном tool
Простое правило:
Бизнес‑tools (поиск товаров, резерв, заявки) — аутентификация пользователя, без переключения на системного. Если пользователю нельзя — агенту от его имени тоже нельзя.
Технические tools (запись логов агента, чтение служебных конфигов, отправка нотификаций) — SystemAuthenticator.runWithSystem(…).
Никогда не используйте runWithSystem для бизнес‑операций «чтобы обойти ошибку доступа». Это всегда ошибка проектирования, а не безопасности.
Как это видно в @CreatedBy
Если вы все сделали правильно — и проброс аутентификации, и @CreatedBy на ReplenishmentRequest — в созданной заявке будет имя пользователя, который инициировал диалог с агентом. В сочетании с initiatedByAgent=true это дает аудитору полную картину: кто инициировал и что это было сделано через AI‑агента, а не через форму.
Если же вы запустили tool в фоновом потоке без проброса — @CreatedBy будет либо пустым, либо равным имени системного пользователя, в зависимости от конфигурации. Это первое, на что стоит смотреть в логе после запуска: если в createdBy у заявки висит system, anonymous или null — значит, контекст не пробрасывается, и вся остальная безопасность тоже не работает.
Права на доступ к атрибутам: то, что DataManager не проверяет автоматически
В первой части мы обещали вернуться к одной оговорке. DataManager применяет права на сущности (CRUD-операции) и row-level constraints автоматически — но read-доступ на отдельные атрибуты на уровне data store не проверяется. Эта проверка живёт исключительно в UI-слое: при выводе поля на форму связывание компонента (UiEntityAttributeContext) прячет или делает доступным только для чтения атрибут, который пользователю видеть (или менять) не положено.
В фоновом сценарии агента UI-слоя нет — а значит, и этой проверки нет. Практически это значит: если на Product.description навешен запрет чтения ролью, а tool загрузил его в fetch plan, значение уйдёт в модель в обход прав. Никакого исключения, никакого предупреждения. Эту проверку нужно сделать руками — и для этого не обязательно переписывать всё на loadValues, как в нашем B2B CRM. Достаточно того же AccessManager-а, что и в UI, только через data-слойный EntityAttributeContext (аналог UiEntityAttributeContext):
@Component
public class AttributeAccessSupport {
private final Metadata metadata;
private final AccessManager accessManager;
public AttributeAccessSupport(Metadata metadata, AccessManager accessManager) {
this.metadata = metadata;
this.accessManager = accessManager;
}
/** Из перечисленных атрибутов оставляет только те, что текущий пользователь вправе читать. */
public List<String> viewable(Class<?> entityClass, String... attributes) {
MetaClass mc = metadata.getClass(entityClass);
List<String> allowed = new ArrayList<>();
for (String attr : attributes) {
EntityAttributeContext ctx = new EntityAttributeContext(mc, attr);
accessManager.applyRegisteredConstraints(ctx);
if (ctx.canView()) {
allowed.add(attr);
}
}
return allowed;
}
} Внедряем AttributeAccessSupport в tools и строим fetch plan не из жёсткого списка, а из разрешённых атрибутов - тогда запрещённый атрибут даже не загрузится:
@Tool(description = "...")
public List<Product> findProducts(@ToolParam(description = "...") String keyword) {
List<String> fields = attributeAccess.viewable(
Product.class, "name", "description", "category");
return dataManager.load(Product.class)
.query("select p from Product p " +
"where lower(p.name) like :kw or lower(p.description) like :kw")
.parameter("kw", "%" + keyword.toLowerCase() + "%")
.maxResults(20)
.fetchPlan(fp -> fp.addAll(fields.toArray(String[]::new)))
.list();
}Теперь граф, который уходит в LLM, ограничен не только нашим fetch plan-ом, но и ролями пользователя. Если нужно не молча скрыть поле, а явно отказать («у вас нет доступа к этому атрибуту»), — проверяйте canView() и бросайте исключение: его текст вернётся модели так же, как любая другая ошибка tool.
Одна честная деталь, чтобы не создавать ложного ощущения полной защиты: в примере выше мы всё ещё ищем по description (он остался в where), даже если читать его пользователю нельзя — по самому факту совпадения можно косвенно судить о содержимом. Если такая утечка критична, исключайте недоступные атрибуты и из условий запроса, опираясь на тот же viewable(...).
Fetch plans в tool‑методах
В первой части мы договорились: tool-методы возвращают сущности с явным fetch plan. Разберём, что это означает и какой план выбрать.
В Джеймикс есть три встроенных плана; плюс всегда доступен кастомный:
_instance_name — только атрибут (или атрибуты в случае метода), аннотированный как @InstanceName. Могут быть локальные или ссылочные.
_local — загружает только локальные (не-ссылочные) поля. Связи не трогает вообще.
_base — то же, что _local, плюс поле или поля из @InstanceName, если они ссылочные. Это один дополнительный JOIN для каждого ссылочного поля, не весь граф. План _base часто обвиняют в том, что он "тянет все связи" — это неточность.
Кастомный план — явный список полей, который вы строите через лямбду или декларативно.
Для tool-методов правильный выбор почти всегда — кастомный план:
dataManager.load(StockItem.class)
.query("select s from StockItem s where s.product.id = :pid")
.parameter("pid", id)
.fetchPlan(fp -> fp
.addAll("quantity", "reserved")
.add("product", pFp -> pFp.addAll("name"))
.add("warehouse", wFp -> wFp.addAll("name")))
.list();Несколько правил:
Включайте только поля, которые нужны модели. Лишние поля — лишние токены и потенциальная утечка данных.
Не используйте _base «на всякий случай» — но не потому что он «тащит всё» (он не тащит), а потому что с кастомным планом вы точно знаете, что попадёт в контекст модели.
Никаких бинарных полей или больших текстов в плане для агента.
Если tool возвращает сущность после save() — перегружайте через dataManager.load() с явным планом. Результат save() несёт тот же план, что был при загрузке аргументов, и это может быть не то, что нужно модели.
Metadata‑aware промпт
System prompt из первой части содержит список tools, но ничего не говорит о доменной модели.
Модель знает, что есть tool findProducts, но не знает, какие ещё категории товаров вообще существуют в системе, какие поля у заявки, какие статусы возможны.Можно перечислить это руками. Это работает, но раздувает промпт и протухает, как только вы добавляете поле в сущность.
В Джеймикс есть Metadata и MetadataTools. С их помощью можно собрать описание модели автоматически:
@Component
public class DomainPromptBuilder {
private final Metadata metadata;
private final MetadataTools metadataTools;
private final MessageTools messageTools;
public DomainPromptBuilder(Metadata metadata,
MetadataTools metadataTools,
MessageTools messageTools) {
this.metadata = metadata;
this.metadataTools = metadataTools;
this.messageTools = messageTools;
}
public String build(Class<?>... entityClasses) {
StringBuilder sb = new StringBuilder("Domain model available via tools:\n\n");
for (Class<?> cls : entityClasses) {
MetaClass mc = metadata.getClass(cls);
sb.append("- ").append(mc.getName())
.append(" (").append(messageTools.getEntityCaption(mc)).append(")\n");
for (MetaProperty p : mc.getProperties()) {
if (metadataTools.isSystem(p)) {
continue;
}
sb.append(" ").append(p.getName())
.append(": ").append(p.getJavaType().getSimpleName())
.append("\n");
}
}
return sb.toString();
}
}И в конфигурации ChatClient:
@Bean
public ChatClient warehouseAgentClient(
ChatClient.Builder builder,
WarehouseAgentTools readTools,
WarehouseWriteTools writeTools,
DomainPromptBuilder promptBuilder,
SystemAuthenticator systemAuthenticator) {
// MessageTools.getEntityCaption читает значение Locale из CurrentAuthentication.
// Мы в самом начале создания бина - пользователя пока нет. Используется system.
String domain = systemAuthenticator.withSystem(() -> promptBuilder.build(
Product.class, Warehouse.class, StockItem.class, ReplenishmentRequest.class));
return builder
.defaultSystem("""
You are a warehouse assistant.
%s
Rules:
- When the user mentions a city, first call listWarehouses.
- When the user describes a product, first call findProducts.
- For write operations, only use ids returned by previous tool calls.
- For reserveStock, always check stock first with getStock.
""".formatted(domain))
.defaultTools(readTools, writeTools)
.build();
}Маленькая, но показательная деталь — systemAuthenticator.withSystem(...) вокруг вызова promptBuilder.build(...). На первом прогоне приложение упало с IllegalStateException: Authentication is not set — потому что MessageTools.getEntityCaption читает локаль из CurrentAuthentication, а на этапе создания бинов никакого пользователя ещё нет.
Это та самая ситуация, ради которой и существует SystemAuthenticator: tool (или конфигурационный код) выполняется не от имени пользователя, нужна явная техническая идентичность. Мы об этом писали выше в разделе про безопасность — и вот живой случай прямо в самой инфраструктуре агента.
Что мы получили:
Описание модели всегда соответствует коду. Добавили поле в Product — оно появилось в промпте без правки текста.
Локализованные caption'ы из message bundles попадают в промпт автоматически. Если у какого-то клиента категория называется не "Category", а "Product line", модель это увидит.
Системные поля (version, deletedDate и т.п.) отфильтрованы через metadataTools.isSystem(p) — они не нужны в промпте и только сбивают модель.
Это та фишка, ради которой стоит делать агента именно как часть Джеймикс-приложения, а не как сбоку прикрученный сервис. Метаданные есть, грех их не использовать.
Валидация того, что возвращает LLM
LLM возвращает строки. Все, что приходит в tool через @ToolParam, было сгенерировано моделью — и почти не подвергалось валидации до этого момента.
Что с этим делать:
UUID — UUID.fromString(...) бросит IllegalArgumentException на мусоре. Это уже валидация. Spring AI обернёт исключение в сообщение для модели, и модель попробует ещё раз — часто успешно.
Числа — не доверяйте знаку и порядку. quantity < 0 — явная и правильная проверка в начале tool вызова.
Enum / статусы — в Джеймикс enum‑ы реализуют EnumClass<T>. Безопасная конверсия строки от модели через fromId():
static ReplenishmentStatus parseStatusFromLlm(String rawValue) {
if (rawValue == null) {
throw new IllegalArgumentException("Status value from LLM is null");
}
String normalized = rawValue.strip().toUpperCase();
ReplenishmentStatus status = ReplenishmentStatus.fromId(normalized);
if (status == null) {
throw new IllegalArgumentException(
"Unknown status value from LLM: '" + rawValue + "'. " +
"Allowed: " + Arrays.toString(ReplenishmentStatus.values()));
}
return status;
}Пояснения:
Нормализация (trim + toUpperCase) нужна потому, что модель может вернуть "new", "New", "NEW ". Вызов fromId() возвращает null для неизвестного значения, и проверка на null обязательна. Никогда не подставляйте строку из модели напрямую в setStatus() без конверсии.
Свободный текст — если поле сохраняется в БД (например, reason в ReplenishmentRequest), ограничьте длину явно. Модель может вернуть несколько тысяч символов, если её не остановить.
Обратите внимание: в findProducts (часть 1) поисковый keyword идёт в запрос через :kw — это bound parameter, JPQL-эквивалент prepared statement. То есть даже если модель вернёт "%' OR 1=1 --", оно уйдёт в запрос как литеральная строка, а не как SQL-конструкция. Это конкретный пример того, как DataManager + bound parameters защищают вас на архитектурном уровне. Никаких String.format(...) или конкатенаций в построении запросов — инъекций через модель не будет.
Правило простое: к параметрам tool относитесь как к параметрам HTTP-эндпоинта от анонимного пользователя. Это ровно тот же уровень доверия.
Один частый антипаттерн — писать в tool что-то вроде следующего кода без контроля:
@Tool(description = "Run a custom query")
public List<?> runQuery(@ToolParam(description = "JPQL query") String jpql) { ... }В некоторых сценариях желание отдать модели значение String jpql и выполнить его как есть — действительно антипаттерн: практически прямой эквивалент SQL injection. Только здесь инъекция инициирована не злоумышленником, а вашей же моделью, которая может ошибиться (или которую можно подтолкнуть к ошибке через prompt injection).
Но категоричное «никогда не давайте модели самой формулировать запросы» было бы неточным — AI-инструменты Джеймикс в B2B CRM, например, дают модели именно такую возможность. Разница не в самом факте, а в обвязке. Открытый запрос отдать модели можно, если он идёт через контур, не позволяющий выйти за пределы прав пользователя:
запрос исполняется через loadValues()-механизм Джеймикс, а права на сущности проверяет LoadValuesAccessContext. Запрос, затрагивающий недоступную пользователю сущность, падает с AccessDeniedException;
только именованные параметры, никаких конкатенаций — та же защита от инъекций, что мы разобрали выше;
обязательная пагинация результата, чтобы модель не вытащила всю таблицу одним запросом;
и, как мы отметили в разделе про безопасность, — атрибутивная фильтрация, которую loadValues сам по себе тоже не делает.
Для этой статьи мы сознательно выбрали другой дефолт — узкие типизированные tools под конкретные сценарии. Они проще, предсказуемее и не требуют отдельного контура валидации запросов. Открытый запрос оправдан, когда заранее неизвестно, какие срезы данных понадобятся (типичный аналитический ассистент). В таких случаях стройте его так, как это делают штатные AI-инструменты, а не как runQuery(String jpql) из примера выше.
Стоимость, недетерминизм, лимиты
Несколько практических наблюдений, которые легко не заметить на демо и потом дорого вспоминать в проде.
- Каждый запрос пользователя — несколько вызовов модели. Один разговор с агентом — это 3–7 round-trip'ов к модели, не один. Бюджет токенов считайте от этого числа.
Контекст растёт. Все tool calls и их результаты сохраняются в контексте текущего диалога. Длинный диалог упирается в лимит контекста модели и начинает терять начало.
Локальная модель тоже не бесплатна. Если используется GPU — это потребление электричества и износ железа. Если CPU — это секунды-минуты на запрос, пользователь может устать ждать ответа.
Недетерминизм. На один и тот же вопрос агент может вызвать tools в чуть-чуть разном порядке. Это не баг, это природа модели. Не закладывайтесь в логику на "модель сделает X, а потом Y" — закладывайтесь в логику на корректность каждого tool по отдельности.
Идемпотентность. Если tool можно вызвать с одинаковыми аргументами дважды без последствий — агент стабильнее. Это не всегда возможно, но к этому стоит стремиться.
Когда AI-ассистент не нужен
Раз уж мы написали две статьи, как его делать, будет честно сказать, когда его делать не надо:
Простой поиск по форме с фильтрами. Если у вас экран с пятью полями фильтра и кнопкой "Найти" — оставьте его. Это быстрее, дешевле, более предсказуемо и не требует объяснения, почему отчёт иногда выглядит чуть по-разному.
Высокая нагрузка. Если эту операцию делает не один пользователь время от времени, а тысяча пользователей одновременно — LLM никогда не выиграет ни по латентности, ни по стоимости.
Жёсткие требования к воспроизводимости. В compliance-сценариях отсутствие детерминизма у агента — проблема, а не фича.
Пользователи, которые не любят свободный ввод. Это реальный сегмент. Многим удобнее использовать привычные фильтры, чем формулировать запрос текстом, особенно на не-родном языке.
Агент хорош в задачах, где много вариативности входа, нужно комбинировать несколько операций и пользователь не хочет учить интерфейс. Везде, где это не так, классический CRUD выигрывает.
Что в итоге получилось
К концу второй части в нашем приложении есть:
read- и write-tools, идущие через DataManager и уважающие безопасность Джеймикс;
корректный проброс аутентификации в фоновый поток агента;
осознанное использование SystemAuthenticator там, где это уместно, и не использование - где не уместно;
fetch plans для тех редких случаев, когда из tool возвращается сущность;
metadata-aware промпт, который держится в актуальном состоянии без ручной правки;
валидация всего, что приходит от модели на входе в tool;
понимание, где этот подход выигрывает, а где нет.
Из того, что осталось за рамками двух статей и тянет на отдельный материал:
Память диалога между сессиями. ChatMemory в Spring AI, её реализации, и как сохранять историю между перезаходами в UI.
RAG поверх документации. Если у вас есть свод регламентов по складу — агент может отвечать со ссылками на конкретные пункты.
Multi-agent. Когда задача разбивается на несколько ролей — "тот, кто ищет", "тот, кто проверяет права", "тот, кто пишет".
Стриминг ответа. Чтобы пользователь видел текст по мере генерации, а не ждал финального блока.
Всё это — продолжения, а не альтернативы тому, что мы построили. Базовый каркас "Джеймикс + Spring AI + tools через DataManager + правильная безопасность" остаётся тот же. Напишите нам, какие ещё темы вам интересно было бы увидеть в виде статей.
Всем спасибо за внимание.
Что почитать дальше
Первая часть этой статьи, если ещё не читали
github.com/jmix-edu/ai-warehouse - полный исходник демо к этой статье.
Spring AI Reference - особенно разделы про ChatMemory и Advisors.
Документация Джеймикс: Security - подробно про роли, политики и SystemAuthenticator.
Документация Джеймикс: DataManager - тонкости работы с DataManager.
Документация Джеймикс: Fetch Plans - детали fetch plans.























