Skip to content

About

Runtime-neutral JavaScript and TypeScript core shared by Mitra SDKs

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Mitra SDK Core

Base comum dos SDKs JavaScript da Mitra: tipos e módulos de API que não dependem de ambiente. App não instala o Core direto. No browser, use @mitralab.io/platform-sdk; dentro de uma Server Function, use @mitralab.io/functions-sdk. Os dois já trazem o Core. Instale este pacote só para escrever um SDK ou adaptador da Mitra para outro runtime.

Instalação

npm install @mitralab.io/sdk-core

Node 18 ou mais novo. Publicado em ESM e CommonJS, com tipos e sem dependências de runtime.

Início rápido

Os módulos de API não fazem HTTP: você entrega um Transport por serviço, que recebe o path e as opções e devolve o corpo já parseado. Autenticação, headers, timeout e erro HTTP ficam com ele. A exceção é o canal direto dos chats de agente (directChannel), que fala com o gateway por WebSocket ou fetch, fora do Transport.

import { createSdkCore, type Transport, type TransportRequestOptions } from "@mitralab.io/sdk-core"

const apiUrl = process.env.MITRA_API_URL!
const accessToken = process.env.MITRA_TOKEN!
const appId = process.env.MITRA_APP_ID!

function fetchTransport(service: string): Transport {
  return {
    async request<T>(path: string, options: TransportRequestOptions = {}): Promise<T> {
      const url = new URL(`${apiUrl}/${service}${path}`)
      for (const [key, value] of Object.entries(options.params ?? {})) {
        for (const item of [value].flat()) {
          if (item !== undefined) url.searchParams.append(key, String(item))
        }
      }
      const headers = new Headers({ "Content-Type": "application/json", ...options.headers })
      headers.set("Authorization", `Bearer ${accessToken}`)
      headers.set("X-App-Id", appId)
      const response = await fetch(url, {
        method: options.method ?? "GET",
        headers,
        body: options.body === undefined ? null : JSON.stringify(options.body),
        redirect: "error",
      })
      if (!response.ok) throw new Error(`Request failed with status ${response.status}`)
      const text = await response.text()
      return (text ? JSON.parse(text) : undefined) as T
    },
  }
}

const core = createSdkCore({
  transports: {
    auth: fetchTransport("iam"),
    dataManager: fetchTransport("data-manager"),
    functions: fetchTransport("functions"),
    integration: fetchTransport("integration"),
  },
  getAppId: () => appId,
})

const { data: tasks, hasMore } = await core.entities.getTable("Task").list({ limit: 20 })

O que dá para fazer

  • entities: CRUD nas tabelas do app, por getTable(nome) ou core.entities.<Tabela>.
  • queries, customQueries, sql e schema: queries salvas, SQL parametrizado e estrutura das tabelas.
  • functions, publicFunctions, functionsAdmin e workflows: executar e administrar Server Functions e workflows.
  • integration, integrationAdmin, integrationResources e integrationTemplates: chamadas a APIs externas e a configuração delas.
  • agentTasks, com createAgentTaskSessionManager e withAgentTaskSessions: chats de agente com send, sendAndWait, cancel e eventos. O gerenciador recebe tasks, o eventSource do seu SDK e directChannel, com o apiUrl do gateway e, se quiser, WebSocket e fetch.
  • apps, context, agents, agentConnections, agentCredentials, members, imports, dataSources e auth: o app em si, a configuração de agentes e os demais recursos.
  • messenger: notify avisa o usuário autenticado e sendEmail manda e-mail com o remetente do app.
  • resolveApiKeyToken, readTokenAppId e tokenAuthorizesApp: contrato da troca de API key por token, para o SDK que autentica por chave.

Configuração

Opção Obrigatória Uso
transports.auth, dataManager, functions, integration sim um Transport para cada serviço
transports.codeStudio, copilot, messenger não sem eles falham com SdkCoreConfigurationError, antes de sair a requisição: apps (codeStudio); agentTasks, agentConnections, agentCredentials e agents.listModels (copilot); messenger. O resto de agents usa functions
transports.publicFunctions não transporte anônimo de publicFunctions, sem Authorization nem X-App-Id
getAppId não devolve o app fixado pelo seu SDK; usado por context
functions.executeInvocationType não sync ou async em functions.execute; sem valor, vale o padrão do servidor
functions.emptyInput não empty-object manda { "input": {} } quando não há input; omit-body não manda corpo
errors não SdkCoreErrorFactory para o seu SDK lançar as próprias classes no lugar de SdkCoreConfigurationError e SdkCoreResponseError

Erros

Erro Quando O que fazer
SdkCoreConfigurationError entrada inválida: path vazio, deleteMany({}), transporte opcional ausente, API key ou app vazios corrija a chamada ou passe o transporte que falta
SdkCoreResponseError (code INVALID_RESPONSE) resposta de sucesso fora do contrato confira se o transporte devolve o corpo parseado; se sim, atualize o Core
AgentTaskTurnError o turno do agente foi recusado ou terminou com erro; code traz o motivo, quando vem mostre o erro e deixe a pessoa mandar de novo
erro do seu Transport falha HTTP ou de rede trate no seu SDK; o Core repassa o erro sem mudar, exceto em agentCredentials.usage e agentConnections.usage, que devolvem null quando o erro traz code CREDENTIAL_USAGE_NOT_FOUND

Boas práticas

  • Fixe o app no adaptador: getAppId e o header X-App-Id vêm do valor configurado no seu SDK, nunca de um argumento de quem chama. Aplique as credenciais depois dos headers de options, como no exemplo, para que eles não as substituam.
  • O Core não repete requisição nem renova token. Se o seu transporte repetir, repita só o que não grava.
  • publicFunctions precisa de um transporte sem credencial. O Core não usa o transporte autenticado no lugar dele.
  • sendEmail({ to, subject, html, text?, cc?, bcc?, replyTo? }) não tem from: o Messenger usa o remetente do app do token. Por isso só o backend do app manda e-mail, com o token trocado pela API key ou o token da Server Function; token de usuário final do app recebe 403. to, cc e bcc são listas com um endereço por item. Os limites ficam no Messenger e chegam como erro do seu Transport: 400 acima do limite de destinatários dele somando os três campos e 429 acima da cota diária do app, com o header Retry-After e retry_after_seconds no corpo.
  • sendAndWait com timeoutMs ou signal só para de esperar; o turno continua no servidor. Para interromper, chame cancel(). A sessão continua acompanhando esse turno: se o canal cair depois do prazo, ela ainda fecha o turno pelo histórico, como faz com um send. Pelo histórico, o turno só fecha na linha que o termina: a do agente com o uso do turno, ou uma linha ERROR. As linhas que o Copilot grava com o turno rodando e a resposta atrasada da pergunta anterior não fecham nada. O stream do Copilot repete o turno em andamento a partir do que o histórico dele tem; o que a sessão já viu, pelo sequence da box, não sai de novo.
  • cancel() chamado antes de a box aceitar o prompt espera esse aceite e só então manda o stop; a promise resolve depois disso. Se o prompt não chegar a virar turno, nada é enviado e o status não passa por cancelled. Mensagem mandada depois do cancel(), com o stop ainda sem resposta, sai como qualquer outra; a box espera o turno parado terminar e dá a ela um turno próprio, e o sendAndWait dela resolve com esse turno. Turno parado que a sessão fecha pelo histórico termina com reason interrupted.
  • Toda mensagem sai na hora, com ou sem turno rodando; a sessão não guarda fila. Com turno rodando, a box coloca a mensagem dentro do turno em andamento (steering nativo do provedor), põe na fila do provedor para um turno próprio, ou dá a ela um turno próprio se o turno já acabou; o sendAndWait de cada mensagem resolve com o turno que a respondeu. No canal direto ela vai pelo socket sem mode por padrão, e o T3 decide como no composer dele: entra no turno em andamento quando o provedor aceita steer, ou espera na fila do T3. send(prompt, { mode: "steer" }) pede o steer de propósito (o T3 recusa enquanto o turno ainda não está rodando) e send(prompt, { mode: "queue" }) pede a fila; steerQueueItem(id) leva um item da fila para o turno em andamento e resumeQueue() retoma a fila que o Stop segurou. A mensagem leva também um clientMessageId, que a box devolve no frame que a aceita (stepStart ou messageQueued, este sem o prazo de 35 s). O send devolve esse clientMessageId (ou undefined para texto em branco, que não sai). Quando o T3 entrega a mensagem ao turno em andamento, a box marca o ponto com um frame steer, sem turno novo: a sessão emite steered com text e messageId, para o app mostrar a mensagem ali, e o turnEnd daquele turno responde a ela. Se o turno acabar antes, ela ganha turno próprio, que abre com stepStart. O clientMessageId também volta no error que a recusa, que rejeita só aquela mensagem. Com o socket reconectando, ou sem canal direto, vai ao Copilot (POST /inputs) com o mesmo clientMessageId, que o Copilot guarda com a mensagem pendente enquanto a box liga e entrega à box, que o devolve igual. A mensagem mandada com turno rodando leva agentType e reasoningEffort para a box, que não troca o modelo do turno em andamento e os usa se a mensagem virar turno próprio; só quando vai ao Copilot com turno rodando eles são tirados, porque o Copilot reinicia a sessão viva para outro modelo. Recusa do Copilot com status de erro rejeita aquela mensagem e emite error. Falha no envio rejeita o sendAndWait e emite error; mensagem que ainda não saiu quando a sessão fecha é rejeitada e não é enviada. A sessão não guarda fila própria: queue e queueChange mostram a fila do T3 que a box informa com messageQueued (só os que têm position), de qualquer cliente do chat, com id (o clientMessageId), text, position, queueHeld, sequence e turnId. A mensagem sai da fila quando um turno a pega (stepStart ou steer com o mesmo clientMessageId), quando a box a tira da fila por um queued-run.cancel (messageQueued com position: null e cancelled: true, sem stepFinish) ou quando ela termina antes de rodar; a saída sem turno rejeita só o sendAndWait dela e não mexe no turno em andamento. Como no T3, cancelar um item da fila só tira aquele item: nenhum turno termina por isso. Se ele era a mensagem que abriu o turno desta sessão, ainda esperando atrás do turno de outro cliente, a sessão segue a próxima mensagem dela na fila quando a box a começa, ou volta a idle se não sobrou nenhuma, sem turnEnd. Frame de mensagem da fila nunca troca o turno que o canal acompanha para reconectar. removeQueueItem(id) manda queued-run.cancel, editQueueItem(id, text) manda queued-run.edit e clearQueue() manda um queued-run.cancel por item; a fila muda quando a box responde, não antes. A recusa de um desses comandos chega como error com command (queued-run.cancel ou queued-run.edit): a sessão só emite o error, a mensagem fica onde está e nenhum turno falha. Os três vão só pelo canal direto; sem ele sai um error e nada é enviado. Depois do replay a box manda de novo a fila e os subagentes como estão, com o sequence da linha que os disse por último: o que a sessão já tem não sai de novo em queueChange nem em subagent, e numa abertura nova isso preenche queue e subagents.
  • Subagentes: o evento subagent traz subagentId, toolId (o toolId do tool que o criou), status (os do T3: pending, running, waiting, completed, failed, cancelled, interrupted, idle; quando o T3 não manda status, fica o anterior), progress, startedAt, endedAt (epoch ms) e childThreadId; session.subagents guarda o último estado de cada um. A conversa do subagente vem em subagentDelta e subagentTool, com parentToolId, e não abre nem fecha o turno do chat. Como na 0.2.13, o texto dela também entra em delta e content, e as ferramentas também saem em tool, com parentToolId. O resultado de subagente em segundo plano que termina depois do turno dono chega como tool de fase result com o toolId da chamada, sem abrir turno nem mexer em status e content. stopSubagent(subagentId) para só ele e deixa o turno seguir; vai só pelo canal direto, e sem ele sai um error e nada é enviado.
  • Peças e ordem: cada evento que já existe leva, como campos opcionais, o que o frame da box trouxe: sequence (a ordem do log da box), itemId (o id do item no T3), turnId (o lifecycle.turnId) e, no texto e nas ferramentas de subagente, parentToolId. O delta (e o subagentDelta) traz também itemDelta, o pedaço do item como a box mandou: igual ao delta, menos no primeiro pedaço de um item de texto que vem depois de outro, em que o delta começa com o separador de linha dupla que a 0.2.13 usava para juntar os dois. Quem desenha por item usa itemDelta. O steered traz também o turnId da mensagem (o id do item dela no histórico), sequence e clientMessageId; o turnEnd traz o sequence e o turnId do stepFinish. O item do history traz o que o Copilot gravou: sequence (o do primeiro pedaço da peça, igual ao do evento ao vivo), lastSequence (o do último pedaço, só quando a peça veio em mais de um), boxLog (qual log da box conta o sequence; box que sobe sem o log recomeça), itemId, parentToolId, clientMessageId na pergunta, subagent na ferramenta que criou o subagente e reason na linha que fechou o turno. O subagent tem o formato do frame subagentUpdate da box, sem conversão. Na ferramenta, o parentToolId fica em tool.parentToolId. O histórico é o streaming gravado, uma linha por peça: o loadHistory pede todas as linhas com kinds=all e cada linha vira um item, como veio. Linha REASONING vira item reasoning (raciocínio, com itemId e, no do subagente, parentToolId); linha SUBAGENT_TEXT vira item agent com parentToolId; ferramenta de subagente é item tool com tool.parentToolId; linha APPROVAL vira item approval e linha USER_INPUT_REQUEST vira item userInputRequest, os dois com o payload do frame da box como veio (o mesmo que chega ao vivo como evento raw). Ao vivo, os frames approval e user_input_request entram no history como esses mesmos itens, na posição em que vieram, igual ao recarregado. A recuperação de turno perdido também pede kinds=all, para o history seguir com todas as peças, e lê a resposta só das linhas do próprio chat (TEXT, ERROR e TOOL_USE sem parentToolId). Com kinds=all, a pergunta que ainda espera na fila não vem no histórico: ela aparece em queue até o turno dela começar. O turnStart aberto pelo stepStart da box traz o text e o clientMessageId da mensagem daquele turno, com sequence, itemId e turnId: é ali que o app mostra a pergunta, também quando o histórico ainda não a tem (F5 logo depois do envio). Sem o parâmetro, o Copilot devolve só as linhas que a 0.2.13 lia. A SDK só repassa: não ordena, não agrupa e não deduz nada. Frame ou linha sem o campo deixa o campo de fora.
  • Turno e tempo de entrega: quem decide se a mensagem entra no turno em andamento ou ganha turno próprio é a box, não o tempo local da SDK. Um stepStart com turnId sem turno rodando abre o turno com o content limpo, e o accepted sai no frame que nomeia a mensagem. Frames que chegam juntos num tick dão os mesmos eventos que frames espaçados.
  • Pergunta do agente no meio do turno: o T3 pode pedir uma resposta à pessoa. O pedido chega como evento raw de tipo user_input_request, com o item do T3 no payload (perguntas e requestId); o turno fica esperando, sem prazo da SDK. Responda com session.respondUserInput(requestId, answers, attachmentsByQuestionId?), que vai direto para a box. Se a box recusar a resposta, sai um error e o turno continua; com o canal direto fora (reconectando ou chat sem box), a resposta não é enviada e sai um error dizendo isso, para o app mandar de novo depois do channelConnected.
  • Portão de rollout da 0.3.0: ela só entra nas SDKs de app (platform-sdk e SDK de Functions) quando o ambiente tiver zero box antiga (template do fork experimental). A box antiga trata a mensagem do meio do turno como início de turno novo, zera o uso e a identidade do turno, e nada disso aparece como erro. A SDK não distingue box antiga de box nova; o portão é o bump nas SDKs de app, que fixam a versão exata desta.
  • Abrir o chat no meio de uma run (F5): o session({ taskId }) carrega o histórico e pede à box o replay a partir de onde ele termina, o maior lastSequence (ou sequence, quando a linha não traz lastSequence) do boxLog mais novo, com o mesmo pedido que o redial já faz e antes de qualquer frame ao vivo. O que o Copilot ainda não gravou chega no replay, e nada do histórico vem duas vezes. No socket, a decisão é a mesma do redial, pelo hello da box: se o boxLog do canal for outro que o do histórico (o Copilot sobe o boxLog quando a box troca de thread) ou o lastSequence do hello for menor que esse ponto, o log é outro e o replay começa do início dele. No HTTP, decide só pelo boxLog, antes de abrir o stream. O cursor do canal começa no ponto do replay, então uma queda no meio dele pede de novo só o que faltou. Histórico sem sequence não pede replay, como antes.
  • O open() do seu eventSource só deve resolver depois que o stream estiver conectado, para nenhum evento do turno se perder.

Desenvolvimento

npm ci
npm run check

O check roda format, lint, typecheck, testes, build, a conferência dos exports e do corpus de contrato em contracts/, e um smoke test do pacote. A publicação no npm sai do workflow Release, sempre antes dos SDKs que dependem do Core.

About

Runtime-neutral JavaScript and TypeScript core shared by Mitra SDKs

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages