Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion asyncapi/action-events.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
20 changes: 20 additions & 0 deletions docs/channel-gateway.md
Original file line number Diff line number Diff line change
@@ -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
и возвращает его типизированное предложение либо вопрос для уточнения.

Голос, файлы, состояние разговора и карточка подтверждения появятся отдельными совместимыми изменениями.
Так текстовый контракт не привязан к конкретному мессенджеру и не выдаёт незавершённые части за готовые.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
8 changes: 8 additions & 0 deletions examples/channel-message.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"requestKey": "message:550e8400-e29b-41d4-a716-446655440000",
"text": "Создай встречу 12 сентября с 10:00 до 10:30 по Москве",
"context": {
"locale": "ru-RU",
"timeZone": "Europe/Moscow"
}
}
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Миграции:
Expand Down
2 changes: 1 addition & 1 deletion openapi/action-api.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion openapi/agent-runtime-api.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
91 changes: 91 additions & 0 deletions openapi/channel-gateway-api.yaml
Original file line number Diff line number Diff line change
@@ -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: Короткое описание ошибки без внутренних данных сервиса.
2 changes: 1 addition & 1 deletion openapi/mcp-gateway-api.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@portable-agent/contracts",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"files": [
"openapi",
Expand All @@ -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 ."
},
Expand Down
59 changes: 59 additions & 0 deletions test/channel-gateway.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
6 changes: 5 additions & 1 deletion test/schemas.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -65,13 +65,17 @@ 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"),
);

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);
});

Expand Down
Loading