BR 6.3 — OpenClaw Agent
Новый репозиторий: erp-openclaw-agent (Node.js + TS + Fastify + pg). ReAct-агент с историей в PostgreSQL.
Создание репозитория
Через GitHub UI создать erp-openclaw-agent (private). Локально: ~/IdeaProjects/erp-openclaw-agent.
Базовая структура:
erp-openclaw-agent/
├── src/
│ ├── server.ts ← Fastify entry
│ ├── routes/
│ │ ├── chat.ts ← POST /api/v1/admin/agent/chat (SSE)
│ │ ├── conversations.ts ← GET / DELETE / close
│ │ └── health.ts ← /internal/agent/health
│ ├── react/
│ │ ├── runner.ts ← ReAct цикл: LLM → tool → LLM
│ │ ├── streamMerger.ts ← Слияние LLM SSE + tool events в один поток клиенту
│ │ └── systemPrompt.ts ← Хардкод системного промпта
│ ├── tools/
│ │ ├── registry.ts ← 5 tools: name, JSON schema, exec()
│ │ ├── findProducts.ts ← HTTP к Catalog Service
│ │ ├── createOrder.ts ← HTTP к Order Service
│ │ ├── getStoplist.ts
│ │ ├── addToStoplist.ts
│ │ └── getSalesSummary.ts
│ ├── db/
│ │ ├── migrations/ ← Liquibase XML
│ │ ├── repository.ts ← conversations + messages
│ │ └── pool.ts ← pg
│ ├── lib/
│ │ ├── auth.ts ← JWT validate, проверка agent.use
│ │ ├── llmClient.ts ← HTTP к LLM-провайдеру (SSE-парсинг)
│ │ ├── pii.ts ← Доп. маскирование при чтении tool-результатов
│ │ └── rateLimit.ts
│ └── config.ts
├── test/
│ ├── react/runner.spec.ts ← E2E через mock LLM + mock downstream
│ ├── tools/registry.spec.ts
│ └── streamMerger.spec.ts
├── Dockerfile
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── .env.example
└── README.md
Задачи
1. Скелет
pnpm init, deps:fastify@5,pg,ioredis,jose,zod,pino,undici- Dev:
tsx,vitest - TypeScript strict
2. БД миграции (Liquibase или встроенный mini-runner)
Для согласованности с другими сервисами — Liquibase + liquibase-cli. Два changeset:
001-create-agent-tables.xml: создать agent_conversations и agent_messages по Data Model.
002-trigger-update-conversation-timestamp.xml: trigger для обновления agent_conversations.updated_at.
3. Системный промпт (react/systemPrompt.ts)
export const SYSTEM_PROMPT = `Ты — AI-помощник владельца кафе в системе Альфа ERP. Отвечаешь на русском.
Доступные инструменты:
- find_products(query, store_id) — поиск товаров по названию
- create_order(store_id, channel, items[{product_id, quantity}]) — создать заказ
- get_stoplist(store_id) — список товаров в стоп-листе
- add_to_stoplist(product_id, store_id) — добавить товар в стоп-лист
- get_sales_summary(store_id, from, to) — сводка продаж за период
Правила:
1. НИКОГДА не выдумывай product_id или store_id — всегда находи их через find_products / контекст.
2. Перед create_order сначала find_products для каждой позиции.
3. Если юзер не указал ТТ — спроси у него (или используй context_store_id если он есть).
4. Отвечай коротко, по делу, на русском. После выполнения — одна фраза про результат (заказ #N, сумма X ₽).
5. Если запрос вне доступных инструментов — извинись и скажи что не умеешь.
6. channel для create_order: takeaway / dine_in / delivery. По умолчанию takeaway если не указано.
`;4. Tool registry (tools/registry.ts)
export interface ToolDef {
name: string;
description: string;
parameters: JSONSchema7;
exec(args: object, ctx: { jwt: string }): Promise<object>;
}
export const TOOLS: Record<string, ToolDef> = {
find_products: { ... },
create_order: { ... },
// ...
};Каждый exec — это HTTP-вызов к downstream-сервису через undici.fetch с Authorization: Bearer ${ctx.jwt}.
5. ReAct runner (react/runner.ts)
async function* run({
conversationId,
userMessage,
jwt,
}: RunInput): AsyncIterableIterator<StreamEvent> {
// 1. Load conversation history from DB
const messages = await loadMessages(conversationId);
messages.push({ role: 'user', content: userMessage });
await saveUserMessage(conversationId, userMessage);
let iteration = 0;
while (iteration++ < MAX_REACT_ITERATIONS) {
// 2. Call LLM
const llmResponse = await llmClient.chat({ messages, tools: TOOL_SCHEMAS, stream: true });
let assistantContent = '';
const toolCalls: ToolCall[] = [];
for await (const chunk of llmResponse) {
if (chunk.delta.content) {
assistantContent += chunk.delta.content;
yield { type: 'content_delta', text: chunk.delta.content };
}
if (chunk.delta.tool_calls) {
// accumulate tool_calls (они приходят чанками в OpenAI-формате)
mergeToolCalls(toolCalls, chunk.delta.tool_calls);
}
}
// 3. Save assistant message
const msgId = await saveAssistantMessage(conversationId, assistantContent, toolCalls);
// 4. If no tool_calls → finish
if (toolCalls.length === 0) {
yield { type: 'message_completed', message_id: msgId, finish_reason: 'stop' };
return;
}
// 5. Execute each tool_call
for (const call of toolCalls) {
yield { type: 'tool_call_started', tool_call_id: call.id, name: call.function.name, arguments: JSON.parse(call.function.arguments) };
try {
const result = await TOOLS[call.function.name].exec(JSON.parse(call.function.arguments), { jwt });
const masked = pii.mask(result);
await saveToolMessage(conversationId, call.id, JSON.stringify(masked));
messages.push({ role: 'assistant', content: assistantContent, tool_calls: toolCalls });
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(masked) });
yield { type: 'tool_call_completed', tool_call_id: call.id, status: 'ok', summary: summarize(result) };
} catch (err) {
await saveToolMessage(conversationId, call.id, JSON.stringify({ error: err.message }));
yield { type: 'tool_call_completed', tool_call_id: call.id, status: 'error', error: err.message };
// продолжаем цикл — LLM получит ошибку и сформулирует ответ
}
}
// 6. Continue loop — LLM получит результаты tools и решит что дальше
}
yield { type: 'error', code: 'MAX_ITERATIONS_REACHED' };
}6. SSE-эндпоинт /api/v1/admin/agent/chat
- Принять
{conversation_id?, message} - Создать SSE-response (
Content-Type: text/event-stream) - Вызвать
run()и стримитьStreamEvent-ы какevent: <type>\ndata: <json>\n\n - При abort соединения —
AbortController→ graceful stop
7. CRUD сессий
GET /conversations— список своих, paginatedGET /conversations/{id}— история, проверка ownershipDELETE /conversations/{id}— каскадное удалениеPOST /conversations/{id}/close— status=closed
8. Permission check
В мидлваре после JWT-валидации:
- Если в JWT есть
permissionsclaim — проверитьagent.use - Если нет — дёрнуть
/auth/meчерез user-service (с кешем в Redis 60 сек)
9. Cron-job для auto-cleanup
node-cron (в Fastify-процессе) — раз в сутки в 03:00 UTC:
DELETE FROM agent_conversations WHERE status='closed' AND updated_at < now() - interval '30 days';10. Dockerfile
Multi-stage node:22-alpine.
11. Тесты
- Юнит на ReAct runner с mock LLM (stream возвращает фиктивные tool_calls) и mock fetch (downstream сервисы)
- Юнит на каждый tool exec — mock fetch, проверка путей и body
- E2E на полный сценарий «создай заказ маргарита/кола» — Mock LLM, Mock Catalog (возвращает товары), Mock Order (возвращает 201)
Готовность
- Репо создан и запушен
- Liquibase миграции применяются на чистую БД
-
pnpm devзапускает сервер -
curl --data '{"message":"привет"}'на/chatстримит ответ - Тесты зелёные
- Docker image собирается
- Соединяется с LLM-провайдером (TBD — см. Overview)