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 — список своих, paginated
  • GET /conversations/{id} — история, проверка ownership
  • DELETE /conversations/{id} — каскадное удаление
  • POST /conversations/{id}/close — status=closed

8. Permission check

В мидлваре после JWT-валидации:

  • Если в JWT есть permissions claim — проверить 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)

Ссылки