Skip to content

Серверный чат партнёра — AGNT2-461 ​

Сервер партнёра может создать личную беседу сотрудника с Admin Agent магазина по явно выданным правам чата. Тот же ключ может иметь права подготовки ресурсов. Одних прав подготовки недостаточно для сообщений и истории. Вход через браузер остаётся отдельным протоколом.

Реализация и доказательства ​

Реализовано в этой ветке; полные результаты проверок записаны в отчёте AGNT2-461. Эти фикстуры не означают слияние, развёртывание, выпуск боевых ключей или проверку настоящего Store/OpenCart.

ДоказательствоСостояние
Ветка AGNT2-461Реализовано; полная API-проверка завершена, оба сбоя устранены целевыми повторными проверками
Базовые PR433 #344, 437 #347, 457 #348; это не выпуск серверного чата
Слияние/развёртывание серверного чатаЭтот worker не выполнял
Локальный mock MCPПроверены HTTP-подготовка, сообщение, повтор, SSE и история без платного провайдера
Настоящие локальные Store/OpenCartКоординатор подтвердил подготовку, грант сотрудника, catalog_get product9001, завершение, повтор, SSE resume и историю; ответ модели синтетический
Live devНе проверен; нужны разрешение оператора и явно выданный server_chat ключ

Каждый внешний сотрудник получает постоянную идентичность в своём namespace, без подмены владельцем команды. Синхронизация требует chat:membership; смена роли owner/admin/member увеличивает membershipVersion. Монотонный sourceRevision отделён от subjectVersion/membershipVersion. Новое неизменившееся подтверждение продлевает аренду максимум на пять минут; повторные и устаревшие подтверждения её не продлевают. Отозванное участие и удалённая идентичность не восстанавливаются. До понижения последнего владельца назначьте замену; отзыв по безопасности не требует назначения нового владельца.

Сначала создайте беседу, затем получите грант Store для conversationId (ChatThread.id), привяжите его с expectedVersion=0 и отправьте человеческое сообщение. Ответ возвращает единственный разрешённый оператором trustedContext MCP server/destination; отсутствие или неоднозначность запрещают работу. У агента только для разговора — созданного с профилем без MCP — MCP нет вовсе: ответ несёт mcp: null, грант Store и привязка не нужны, GET context сообщает «неактивен», PUT context отклоняется. Зашифрованный грант привязан к сотруднику, организации, окружению, команде, магазину/агенту, беседе и адресу. Статические initialize/tools/list не дают данных магазина. Идентификаторы заголовков хода и MCP-вызова создаёт сервер.

Квитанции идемпотентности переживают ротацию ключа: повтор возвращает прежнюю беседу/запуск, изменённый запрос — 409 CHAT_CONFLICT. Параллельные сообщения конфликтуют при активном запуске. Разрыв соединения не отменяет ход. Неопределённый начатый запуск становится interrupted и автоматически не повторяется. История идёт от новых сообщений к старым, продолжение — nextCursor. Ход с done.stopReason: completed получает completed, даже после ошибок отдельных инструментов. Только завершение из-за tool_error даёт failed / TOOL_REFUSED; max_iterations остаётся limited. Итог определяют run_status или polling запуска. Оба содержат toolErrors: [{toolCallId, name, errorCode}]; без ошибок это пустой массив. errorCode — безопасный TOOL_FAILED, без сырых аргументов и результатов. Строгий клиент может считать любой запуск с непустым toolErrors неудачным.

Текущие ключ, организация, внешняя идентичность, фактическое участие/роль, эпоха и аренда проверяются при допуске, выдаче потока и внешних действиях. Общие заметки/курация, устройства и инструменты управления агентами исключены. Worker доступен только по собственному гранту агента, утверждённому оператором (setupProfile.worker.tools в зафиксированном профиле provisioning); воркер поднимается по полномочиям этого хода, которые проверяются заново при разборе очереди, а не по членству в команде, которого у внешнего человека нет; квоты, выбранная модель, учёт расхода и trusted MCP сохранены. Удаление беседы/агента/команды стирает содержимое и события запусков, сохраняя неактивную привязку и квитанции. Более широкие AGNT2-442/444 не объявлены завершёнными.

Ошибки: CREDENTIAL_REJECTED 401; CHAT_UNAVAILABLE 404; CHAT_CONFLICT 409; MCP_CONTEXT_CONFLICT 409; CHAT_INVALID 400; CHAT_CONFIGURATION 503; EXECUTION_DENIED и MCP_CONTEXT_DENIED. Сырые ошибки провайдера не выдаются. Для dev-проверки нужны развёрнутые маршруты, профиль оператора и ключ с явно одобренными правами чата; существующие права не расширяются.

HTTP contract ​

Authorization: Bearer <organization key with required chat scope>; base /v1/orgs/:orgId/chat. JSON: {success:true,data:...}. PARTNER_API_ENVIRONMENT=test|production is independent of NODE_ENV.

Method/pathScopeRequestResponse data
PUT /membershipschat:membership{externalUserId, externalTeamId, sourceRevision, role, active, validUntil}{actorId,teamId,sourceRevision,role,subjectVersion,membershipVersion,active,validUntil}
POST /conversationschat:create{externalUserId,externalTeamId,agentId} + Idempotency-Key`{conversationId,actorId,teamId,agentId,mcp:
GET /conversations/:conversationId/contextchat:contextactor query{version,expiresAt,active} (no grant)
PUT /conversations/:conversationId/contextchat:context{externalUserId,externalTeamId,serverId,destination,grant,expiresAt,expectedVersion}{version}
POST /conversations/:conversationId/attachmentschat:runmultipart file + fields externalUserId, externalTeamId201 {id,kind,mimeType,name,size,createdAt}
POST /conversations/:conversationId/messageschat:run{externalUserId,externalTeamId,message?,attachmentIds?,timezone?} + Idempotency-Key202 {runId,conversationId,turnId,status,reason,createdAt}
GET /conversations/:conversationId/runs/:runIdchat:readquery externalUserId,externalTeamIdsame run shape
GET /conversations/:conversationId/runs/:runId/eventschat:readquery externalUserId,externalTeamId; Last-Event-ID optionalSSE id=runId:sequence, event=type, data=JSON
GET /conversations/:conversationId/messageschat:readquery externalUserId,externalTeamId,cursor?,limit? (1–200, default 50){items:[{id,turnId,role,text,createdAt,attachments,parts?}],nextCursor}
GET /conversations/:conversationId/attachments/:attachmentId/contentchat:readactor query, or none with chat:conversation:readthe file's bytes with its Content-Type

Вложения — AGNT2-622 ​

Те же файлы и правила, что в чате приложения Agentfy (api/src/slices/agent/chat/domain/chatAttachment.types.ts):

  • Типы: картинки image/png, image/jpeg, image/gif, image/webp (kind: "image", модель их видит); текст text/plain, text/markdown, text/csv, application/json (kind: "text", модель читает первые 20 000 символов, и ей сообщается, что файл показан не целиком).
  • Лимиты: 3 МиБ на файл, 5 файлов на сообщение. Загруженный, но не отправленный файл удаляется через час.
  • Порядок: загрузить каждый файл → отправить сообщение с их id в attachmentIds. message может быть пустым, если attachmentIds не пуст. Id проверяются при приёме сообщения; повтор с тем же Idempotency-Key возвращает исходный запуск.
  • Доступ: файл принадлежит беседе, в которую загружен. Другая беседа или организация не отправит и не прочитает его даже по правильному id — ответ 404. Загружать в беседу может только её актор.
  • История: у каждого сообщения есть attachments: [{id,kind,mimeType,name,size,createdAt}], у ответов агента — [].
  • Содержимое: отдаётся с Cache-Control: private, no-store, X-Content-Type-Options: nosniff и Content-Security-Policy: default-src 'none'; sandbox, поэтому текстовый файл никогда не отрисуется как разметка.
  • Гостевые беседы файлы пока не принимают: 403 CHAT_ATTACHMENT_NOT_ALLOWED.
  • Хранение: файл живёт столько же, сколько беседа, и удаляется вместе с её потоком (при удалении агента). Закрытие гостевой беседы не удаляет ни поток, ни файлы.

Ошибки: CHAT_ATTACHMENT_TYPE_NOT_SUPPORTED 415 (с acceptedTypes), CHAT_ATTACHMENT_TOO_LARGE 413 (с maxBytes), CHAT_ATTACHMENT_TOO_MANY 400, CHAT_ATTACHMENT_NOT_FOUND 404, CHAT_ATTACHMENT_NOT_ALLOWED 403, VALIDATION_ERROR 400 для сообщения без текста и без файлов.

SSE ​

id: <runId>:<sequence> / event: <type> / data: <JSON> / blank line. Last-Event-ID resumes after the last delivered sequence. Public tool-call IDs are server-minted; grants never enter event payloads.

eventJSON data
turn{type:"turn",turnId:string,ts:ISO8601}
text{type:"text",text:string} (append delta)
tool_call{type:"tool_call",name:string,id:string,detail?:string}
tool_result{type:"tool_result",name:string,ok:boolean,error?:string}; no raw tool arguments/results
done`{type:"done",stopReason:"completed"
run_status`{type:"run_status",status:"completed"
error{type:"error",code:string}; safe execution code (for example CHAT_EXECUTION_FAILED); poll run for status

Имя SSE-события всегда совпадает с data.type. Если ход дошёл до конца, включая max_iterations, done приходит перед run_status. thinking намеренно скрыт: внутренние рассуждения могут содержать чувствительный контекст. Необязательный detail инструмента опущен; ошибки инструмента содержат безопасный код TOOL_FAILED, а не текст провайдера. Журнал диалога сохраняет отдельный словарь turn.started / text.delta / tool.started / tool.finished.

Reproducible local proof ​

sh
node api/scripts/partner-chat-smoke.mjs --check
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --test
# Independent, no paid provider; uses reserved workspace API port + 5 / + 6:
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --fixture-mock
# Actual local Store MCP; private input JSON required:
PARTNER_CHAT_SMOKE_OPT_IN=TEST_ONLY node api/scripts/partner-chat-smoke.mjs --fixture

--test uses workspace TEST_DB_NAME, the real HTTP API, provisioning, orchestrator, model resolution, quotas, usage meter and HTTP MCP. Only completion/embedding providers are replaced. Fixture usage records are synthetic; no paid provider call occurs. Do not run concurrent suites on this database. --fixture* uses a separate _test_integration database and stops after 30 minutes.

Private input: .orca/logs/store-fixture-input.json, mode 0600, {mcpTransportUrl,staticCredential,toolName,toolInput}. Only an explicitly approved read-only tool and exact loopback HTTP/HTTPS endpoint on a registered port are accepted. HTTPS trusts a process-specific NODE_EXTRA_CA_CERTS; never disable certificate validation. The test-only transport maps canonical https://93.184.216.34/mcp to that local endpoint. This does not alter production egress.

Private output: .orca/logs/server-chat-fixture.json, mode 0600, contains fixture apiOrigin, organizationId, namespace, environment, distinct provisioningKey/chatKey, destination, staticCredential, expiresAt. Never paste this file into logs, chat, commits or browser bundles. Mock mode also supplies grantIssuerUrl/grantIssuerKey; POST {conversationId,agentId,destination} using bearer grantIssuerKey returns {grant,expiresAt}. This grant issuer is test-only, not the Store protocol.

Восстановление контекста: если ответ привязки потерян, сначала получите актуальную версию через GET. Отсутствующая привязка возвращает {version:0,expiresAt:null,active:false}, истёкшая сохраняет фактическую версию. Отозванная привязка остаётся запрещённой. Выпустите новый грант и выполните привязку с полученной версией; конфликт CAS требует повторного GET. Не угадывайте версию и не повторяйте истёкший грант.

Для независимого экземпляра задайте PARTNER_CHAT_FIXTURE_WORKSPACE=/absolute/initialized-worktree. Сначала зарезервируйте порт API workspace + 5 для API фикстуры и + 6 для mock MCP. Используются отдельные тестовая БД, закрытые файлы и Redis. Перезапуск создаёт новые ключи и instanceId: перечитайте закрытый файл и удалите привязки старого экземпляра. fixtureExpiresAt задаёт 30-минутный срок.

Время хода ​

GET …/conversations/:conversationId/runs/:runId возвращает timings в миллисекундах. У нового запуска есть предварительный снимок (complete: false); итог сохраняется при завершении, ошибке или отмене. После аварийной остановки снимок может остаться неполным. Для старых запусков без измерений — null. Сравнивайте длительности после перехода запуска в конечное состояние.

queuedMs — от принятия до захвата запуска; admissionMs — допуск, lockMs — захват блокировки, agentMs — подготовка агента, promptMs — контекст и память, toolSetupMs — обнаружение MCP, workerMs — постановка воркера и ожидание сессии, classificationMs — выбор уровня модели, modelMs — модель, toolMs — инструменты без ожидания воркера, historyMs — запись истории, otherMs — остальная оркестрация и доставка потока. setupMs — сумма admission/agent/prompt/toolSetup/worker/classification.

queuedMs + setupMs + lockMs + modelMs + toolMs + historyMs + otherMs = totalMs с погрешностью чисел с плавающей точкой. Компоненты setupMs повторно не складывают. Вложенные этапы не пересекаются. Очередь измеряется по часам, исполнение — монотонным таймером. totalMs заканчивается после записи истории и освобождения блокировки, до финальной доставки партнёрского статуса. У прямого хода без очереди queuedMs = 0.

iterations содержит modelMs, firstTokenMs (до первого непустого текстового/мыслительного фрагмента; null без потока или фрагмента), toolMs, упорядоченные tools: [{ durationMs }] и доступные счётчики usage: inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheWrite1hTokens. wallMs — полное время запроса модели, включая доставку фрагментов; modelMs — только ожидание модели, доставка входит в otherMs. firstTokenMs — часть wallMs. wallMs к сумме этапов не прибавляют. Повторы провайдера входят в модель. Согласование MCP внутри вызова входит в инструмент, обнаружение — в подготовку.

Одна JSON-строка event: "turn_timings", turnId, timings на ход содержит те же числа. В ней нет сообщений, промптов, секретов, названий/аргументов/результатов инструментов и текста ошибок. Отсутствующие счётчики неизвестны, а не равны нулю.

Итоговый run_status содержит тот же снимок timings, что и объект запуска, рядом с toolErrors. Для старых запусков без измерений остаётся null.

Расход и стоимость хода ​

GET run и терминальный run_status содержат один и тот же usage:

json
{"model":"claude-sonnet-5-5","inputTokens":9120,"outputTokens":410,"cacheReadTokens":0,"cacheWriteTokens":0,"costUsd":"0.033510000000","priceSide":"cost","kinds":[{"kind":"llm_tokens","costUsd":"0.033510000000"}]}

costUsd — точная десятичная строка с 12 знаками: сумма сохранённых BillingEvent.costUsdPico данного turnId, делённая на 10^12. Используйте decimal, не floating point. priceSide: "cost" означает себестоимость Agentfy по UsagePrice.side=cost, а не цену перепродажи. Кредиты партнёра и charge вне задачи.

Счётчики суммируют все вызовы хода, включая инструменты, повторы и выбор модели. cacheWriteTokens объединяет запись кеша на 5 минут и на час. model — последняя потоковая модель хода, иначе последняя токеновая модель хода; null, если её нет. При нескольких моделях models содержит для каждой {model,inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,costUsd}. kinds: [{kind,costUsd}] всегда разбивает общую стоимость по видам, включая нетокеновые события хода, например runtime_seconds. Сумма kinds равна итогу; стоимость models относится только к токенам.

usage: null возвращается для accepted/running, исторических ходов, отсутствия записей, хотя бы одного события без цены, неудачной записи журнала или неопределённого расхода провайдера (cancelled/interrupted либо исключение). У failed с полностью записанными ответами модели (например отказ инструмента) расход может быть известен. Null — «не учтено», а не «бесплатно»; частичный итог намеренно не выдаётся за полный.

Перед обычным терминальным результатом сервер дожидается записи журнала и атомарно сохраняет снимок вместе с событием. GET и повтор SSE читают тот же снимок, не пересчитывая его при изменении цен или удалении старого журнала. Терминальный null остаётся null. Пока ход активен, перечитывайте GET до терминального статуса. Расход без привязки к этому turnId (например позднее завершение сессии воркера) не входит в стоимость хода. Доступ — прежний server_chat и проверка владельца разговора.

Части хода в истории ​

GET …/conversations/:conversationId/messages сохраняет пагинацию от новых сообщений к старым и прежний склеенный text без изменений (поле API называется text, не content). У сообщений assistant появляется необязательный массив parts в порядке журнала:

json
[{"type":"text","text":"Проверяю магазин."},{"type":"tool_call","toolCallId":"call-1","name":"store_status","status":"succeeded"},{"type":"text","text":"Магазин доступен."}]

Соседние текстовые фрагменты объединяются; вызовы инструментов разделяют текстовые части. Пробелы и разделители шагов сохраняются из журнала. Последняя text-часть — ответ, если ход дошёл до ответа; предыдущие — примечания о ходе работы. Прерванный или ограниченный ход может содержать только примечания: проверяйте статус запуска. Отдельного дублирующего поля answer нет: журнал остаётся единственным источником порядка частей.

Статус инструмента: succeeded, failed или cancelled. Сырые аргументы, результаты и внутренние рассуждения не выдаются. Ошибка инструмента сама по себе не означает провал запуска. У старых сообщений без журнала parts отсутствует: показывайте прежний text, не угадывая границы. Поле также отсутствует, пока в журнале нет завершающего события инструмента. У user-сообщений parts нет.

На страницу выполняется одна выборка журнала по ходам assistant-сообщений внутри разговора, независимо от числа сообщений. Разбора склеенного текста и запросов на каждое сообщение нет. Пагинация и загрузка вложений не меняются. Семидневное окно повтора SSE не ограничивает эту проекцию истории.