From 0996f8604e06de059db1d771c95ade37ed36745c Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Mon, 14 Sep 2026 01:14:17 +0300 Subject: [PATCH] feat: add Channel Gateway API contract --- .github/workflows/ci.yml | 3 +- README.md | 3 +- asyncapi/action-events.yaml | 2 +- docs/channel-gateway.md | 20 +++++++ docs/index.md | 1 + examples/channel-message.valid.json | 8 +++ mkdocs.yml | 2 + openapi/action-api.yaml | 2 +- openapi/agent-runtime-api.yaml | 2 +- openapi/channel-gateway-api.yaml | 91 +++++++++++++++++++++++++++++ openapi/mcp-gateway-api.yaml | 2 +- package.json | 4 +- test/channel-gateway.test.mjs | 59 +++++++++++++++++++ test/schemas.test.mjs | 6 +- 14 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 docs/channel-gateway.md create mode 100644 examples/channel-message.valid.json create mode 100644 openapi/channel-gateway-api.yaml create mode 100644 test/channel-gateway.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a538882..385778f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,8 @@ jobs: strategy: fail-fast: false matrix: - contract: [action-api, mcp-gateway-api, agent-runtime-api] + contract: + [action-api, mcp-gateway-api, agent-runtime-api, channel-gateway-api] steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: diff --git a/README.md b/README.md index 0929a50..35fa70d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ OpenAPI в pull request с `main` и блокирует нарушение эт ## Версии и release -Текущая версия — `2.1.0`. Переход с `1.x` описан в +Текущая версия — `2.2.0`. Переход с `1.x` описан в [migration guide](docs/migrations/2.0.0.md). Версия в `package.json`, OpenAPI и AsyncAPI должна совпадать. Тег `vX.Y.Z` запускает release workflow. @@ -43,6 +43,7 @@ Bundle получает SHA-256 и GitHub artifact attestation. Сервисы - [Контракт создания встречи](docs/calendar-event.md) - [Контракт MCP Gateway](docs/mcp-gateway.md) - [Контракт Agent Runtime](docs/agent-runtime.md) +- [Контракт Channel Gateway](docs/channel-gateway.md) - [Разработка](docs/development.md) - [Диагностика](docs/runbook.md) - [Правила для AI-агентов](AGENTS.md) diff --git a/asyncapi/action-events.yaml b/asyncapi/action-events.yaml index 8d8efd7..378648b 100644 --- a/asyncapi/action-events.yaml +++ b/asyncapi/action-events.yaml @@ -1,7 +1,7 @@ asyncapi: 3.0.0 info: title: Portable Agent Action Events - version: 2.1.0 + version: 2.2.0 description: События жизненного цикла безопасных действий. defaultContentType: application/json servers: diff --git a/docs/channel-gateway.md b/docs/channel-gateway.md new file mode 100644 index 0000000..7a62760 --- /dev/null +++ b/docs/channel-gateway.md @@ -0,0 +1,20 @@ +# Контракт Channel Gateway + +`POST /api/v1/messages` является общим текстовым входом для Web, Telegram и будущих каналов. Адаптер +приводит вход пользователя к одному формату. Channel Gateway не знает Telegram chat id и не принимает +`tenantId` или `userId` в JSON. + +## Первый инкремент + +Запрос содержит: + +- `requestKey` — стабильный ключ повтора от адаптера; +- `text` — уже распознанный текст; +- `locale` и `timeZone` — настройки представления пользователя. + +Личность берётся только из проверенного Bearer JWT. Список доступных коннекторов не приходит от клиента: +его задаёт доверенная серверная конфигурация. В первом инкременте Gateway передаёт запрос Agent Runtime +и возвращает его типизированное предложение либо вопрос для уточнения. + +Голос, файлы, состояние разговора и карточка подтверждения появятся отдельными совместимыми изменениями. +Так текстовый контракт не привязан к конкретному мессенджеру и не выдаёт незавершённые части за готовые. diff --git a/docs/index.md b/docs/index.md index a19b27a..9768b79 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,4 +12,5 @@ Начните с [архитектуры](architecture.md), затем прочитайте [правила разработки](development.md). Внутренний вызов MCP описан в [MCP Gateway API](mcp-gateway.md). Подготовка предложения из текста описана в [Agent Runtime API](agent-runtime.md). +Общий текстовый вход каналов описан в [Channel Gateway API](channel-gateway.md). Для перехода с `1.x` на текущую версию откройте [migration guide 2.0.0](migrations/2.0.0.md). diff --git a/examples/channel-message.valid.json b/examples/channel-message.valid.json new file mode 100644 index 0000000..10b407a --- /dev/null +++ b/examples/channel-message.valid.json @@ -0,0 +1,8 @@ +{ + "requestKey": "message:550e8400-e29b-41d4-a716-446655440000", + "text": "Создай встречу 12 сентября с 10:00 до 10:30 по Москве", + "context": { + "locale": "ru-RU", + "timeZone": "Europe/Moscow" + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 93f6623..2cb8737 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,8 @@ nav: - Архитектура: architecture.md - Создание встречи: calendar-event.md - MCP Gateway API: mcp-gateway.md + - Agent Runtime API: agent-runtime.md + - Channel Gateway API: channel-gateway.md - Разработка: development.md - Эксплуатация: runbook.md - Миграции: diff --git a/openapi/action-api.yaml b/openapi/action-api.yaml index 05c45d1..2724073 100644 --- a/openapi/action-api.yaml +++ b/openapi/action-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Action API - version: 2.1.0 + version: 2.2.0 description: Создание, просмотр и подтверждение безопасных действий агента. license: name: Apache-2.0 diff --git a/openapi/agent-runtime-api.yaml b/openapi/agent-runtime-api.yaml index 678bc21..16dda7d 100644 --- a/openapi/agent-runtime-api.yaml +++ b/openapi/agent-runtime-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent Runtime API - version: 2.1.0 + version: 2.2.0 description: Подготовка предложения действия из текста пользователя. license: name: Apache-2.0 diff --git a/openapi/channel-gateway-api.yaml b/openapi/channel-gateway-api.yaml new file mode 100644 index 0000000..3801b32 --- /dev/null +++ b/openapi/channel-gateway-api.yaml @@ -0,0 +1,91 @@ +openapi: 3.1.0 +info: + title: Portable Agent Channel Gateway API + version: 2.2.0 + description: Единый текстовый вход для независимых каналов пользователя. + license: + name: Apache-2.0 + identifier: Apache-2.0 +servers: + - url: https://api.portable-agent.dev/channel-gateway +security: + - bearerAuth: [] +paths: + /api/v1/messages: + post: + tags: [messages] + operationId: createMessage + summary: Обработать текстовое сообщение + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MessageRequest" + responses: + "200": + description: Предложение действия или вопрос для уточнения + content: + application/json: + schema: + $ref: "./agent-runtime-api.yaml#/components/schemas/ProposalResponse" + "401": + description: JWT отсутствует или не подходит Channel Gateway + "422": + description: Запрос не соответствует контракту + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + "502": + description: Agent Runtime временно недоступен + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + MessageRequest: + type: object + additionalProperties: false + required: [requestKey, text, context] + properties: + requestKey: + type: string + minLength: 1 + maxLength: 200 + pattern: "^[A-Za-z0-9._:-]+$" + text: + type: string + minLength: 1 + maxLength: 10000 + context: + $ref: "#/components/schemas/MessageContext" + MessageContext: + type: object + additionalProperties: false + required: [locale, timeZone] + properties: + locale: + type: string + minLength: 2 + maxLength: 16 + timeZone: + type: string + minLength: 1 + maxLength: 100 + pattern: "^(UTC|[A-Za-z_]+(?:/[A-Za-z0-9_+-]+)+)$" + Problem: + type: object + additionalProperties: true + required: [detail] + properties: + detail: + description: Короткое описание ошибки без внутренних данных сервиса. diff --git a/openapi/mcp-gateway-api.yaml b/openapi/mcp-gateway-api.yaml index 4a5bcca..0c267ee 100644 --- a/openapi/mcp-gateway-api.yaml +++ b/openapi/mcp-gateway-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Portable Agent MCP Gateway API - version: 2.1.0 + version: 2.2.0 description: Безопасный внутренний вызов разрешённых tools в настроенных MCP-сервисах. license: name: Apache-2.0 diff --git a/package.json b/package.json index 161b800..0560931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@portable-agent/contracts", - "version": "2.1.0", + "version": "2.2.0", "private": true, "files": [ "openapi", @@ -17,7 +17,7 @@ "scripts": { "lint": "redocly lint openapi/*.yaml && prettier --check .", "test": "node --test test/*.test.mjs", - "build": "redocly build-docs openapi/action-api.yaml --output dist/action-api.html && redocly build-docs openapi/mcp-gateway-api.yaml --output dist/mcp-gateway-api.html && redocly build-docs openapi/agent-runtime-api.yaml --output dist/agent-runtime-api.html", + "build": "redocly build-docs openapi/action-api.yaml --output dist/action-api.html && redocly build-docs openapi/mcp-gateway-api.yaml --output dist/mcp-gateway-api.html && redocly build-docs openapi/agent-runtime-api.yaml --output dist/agent-runtime-api.html && redocly build-docs openapi/channel-gateway-api.yaml --output dist/channel-gateway-api.html", "pack:contracts": "pnpm pack --pack-destination dist", "format:check": "prettier --check ." }, diff --git a/test/channel-gateway.test.mjs b/test/channel-gateway.test.mjs new file mode 100644 index 0000000..fd48ddd --- /dev/null +++ b/test/channel-gateway.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import YAML from "yaml"; + +const readApi = async () => + YAML.parse(await readFile("openapi/channel-gateway-api.yaml", "utf8")); + +test("Channel Gateway exposes one protected message endpoint", async () => { + const api = await readApi(); + const create = api.paths["/api/v1/messages"].post; + + assert.deepEqual(create.security, [{ bearerAuth: [] }]); + assert.ok(create.responses["200"]); + assert.ok(create.responses["401"]); + assert.ok(create.responses["422"]); + assert.ok(create.responses["502"]); +}); + +test("message request is channel-neutral and contains no identity", async () => { + const api = await readApi(); + const request = api.components.schemas.MessageRequest; + const context = api.components.schemas.MessageContext; + const example = JSON.parse( + await readFile("examples/channel-message.valid.json", "utf8"), + ); + const schema = structuredClone(request); + schema.properties.context = context; + const ajv = new Ajv2020({ allErrors: true }); + + assert.equal(ajv.validate(schema, example), true, JSON.stringify(ajv.errors)); + assert.deepEqual(request.required, ["requestKey", "text", "context"]); + assert.equal(request.properties.channel, undefined); + assert.equal(request.properties.userId, undefined); + assert.equal(request.properties.tenantId, undefined); + assert.equal(context.properties.availableConnectors, undefined); + assert.equal(request.additionalProperties, false); + assert.equal(context.additionalProperties, false); +}); + +test("message response reuses Agent Runtime proposal response", async () => { + const api = await readApi(); + const response = + api.paths["/api/v1/messages"].post.responses["200"].content[ + "application/json" + ].schema; + + assert.equal( + response.$ref, + "./agent-runtime-api.yaml#/components/schemas/ProposalResponse", + ); +}); + +test("compatibility workflow checks Channel Gateway contract", async () => { + const workflow = await readFile(".github/workflows/ci.yml", "utf8"); + + assert.match(workflow, /channel-gateway-api/); +}); diff --git a/test/schemas.test.mjs b/test/schemas.test.mjs index 16158fe..6b2ea4a 100644 --- a/test/schemas.test.mjs +++ b/test/schemas.test.mjs @@ -13,7 +13,7 @@ const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); test("breaking policy reads the real OpenAPI version", async () => { const source = await readFile("openapi/action-api.yaml", "utf8"); - assert.equal(readVersion(source), "2.1.0"); + assert.equal(readVersion(source), "2.2.0"); }); test("compatibility workflow skips policy when oasdiff finds no breaking changes", async () => { @@ -65,6 +65,9 @@ test("all contract files use the package version", async () => { const agentApi = YAML.parse( await readFile("openapi/agent-runtime-api.yaml", "utf8"), ); + const channelApi = YAML.parse( + await readFile("openapi/channel-gateway-api.yaml", "utf8"), + ); const asyncApi = YAML.parse( await readFile("asyncapi/action-events.yaml", "utf8"), ); @@ -72,6 +75,7 @@ test("all contract files use the package version", async () => { assert.equal(openApi.info.version, packageData.version); assert.equal(gatewayApi.info.version, packageData.version); assert.equal(agentApi.info.version, packageData.version); + assert.equal(channelApi.info.version, packageData.version); assert.equal(asyncApi.info.version, packageData.version); });