@softize/opus 25.1.0 → 25.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,17 @@ import { createPurgeHandler, entityTableName, type PurgeHandler } from './retent
25
25
  import type { DataProductConfig } from './data-product.ts'
26
26
  import { AuditEmitter } from './audit.ts'
27
27
  import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
28
+ import {
29
+ assertAssistantDeclaration,
30
+ projectAssistantContext,
31
+ publicAssistant,
32
+ resolveAssistantTitle,
33
+ type AssistantOmission,
34
+ type ReadResourceOptions,
35
+ type ResourceContext,
36
+ type RegisteredAssistant,
37
+ type ResourceReadResult,
38
+ } from './assistant.ts'
28
39
  import { error, isActionError, normalizeError } from './errors.ts'
29
40
  import { normalizeTraceContext } from './trace.ts'
30
41
  import { isSystemActor, markSystemActor } from './system-actor.ts'
@@ -78,6 +89,7 @@ import type {
78
89
  BoundAi,
79
90
  AiTool,
80
91
  AIConfig,
92
+ ActionAssistant,
81
93
  User,
82
94
  TraceContext,
83
95
  } from './types.ts'
@@ -321,6 +333,8 @@ export class Runtime {
321
333
  private aiToolsCache: AiTool[] | null = null
322
334
 
323
335
  private readonly actions = new Map<string, ActionDef>()
336
+ /** A action dona de cada tipo de recurso com assistente (ADR 0053). */
337
+ private readonly assistantOwners = new Map<string, ActionDef & { assistant: ActionAssistant }>()
324
338
  private readonly reactions = new Map<string, ReactionDef<any>>()
325
339
  private readonly schedules = new Map<string, ScheduleDef>()
326
340
  private readonly dataProducts = new Map<string, DataProductConfig>()
@@ -532,11 +546,33 @@ export class Runtime {
532
546
  'Use simple ou form para escrever (ADR 0041).',
533
547
  })
534
548
  }
549
+ this.claimAssistant(action)
535
550
  this.actions.set(action.name, action)
536
551
  if (this.config.dev) this.warnIgnoredExecutionFields(action)
537
552
  }
538
553
  }
539
554
 
555
+ /**
556
+ * Valida a declaração do assistente e reserva o tipo de recurso para a action (ADR 0053). Duas
557
+ * actions donas do mesmo recurso são recusadas com `runtime.assistant_duplicate_resource`.
558
+ */
559
+ private claimAssistant(action: ActionDef): void {
560
+ assertAssistantDeclaration(action)
561
+ const assistant = (action as { assistant?: ActionAssistant }).assistant
562
+ if (assistant === undefined) return
563
+ const owner = this.assistantOwners.get(assistant.resource)
564
+ if (owner !== undefined) {
565
+ throw error({
566
+ code: 'runtime.assistant_duplicate_resource',
567
+ category: 'internal',
568
+ message:
569
+ `O recurso "${assistant.resource}" já tem a action dona "${owner.name}"; ` +
570
+ `"${action.name}" não pode declarar o mesmo resource (ADR 0053).`,
571
+ })
572
+ }
573
+ this.assistantOwners.set(assistant.resource, action as ActionDef & { assistant: ActionAssistant })
574
+ }
575
+
540
576
  /**
541
577
  * `emits` e `background` pertencem ao binding de qualquer kind pelo tipo, mas o runtime e o
542
578
  * manifest só os aplicam em `simple` e `form`. Restringir o tipo quebraria bindings
@@ -726,6 +762,103 @@ export class Runtime {
726
762
  return this.executeAction(action, input, ctxBase, 'request')
727
763
  }
728
764
 
765
+ /**
766
+ * Lê um recurso pela action dona do tipo (ADR 0053). Executa a action com `{ id }` pelo mesmo
767
+ * `execute` de qualquer chamada: autorização, loaders, auditoria, marca de dado pessoal e span são
768
+ * os da action. Em caso de sucesso, devolve o descritor e, com `{ context: true }`, a projeção
769
+ * limitada que o host pode entregar ao modelo. Em caso de falha, devolve o erro da action. Um tipo
770
+ * sem action dona devolve `runtime.assistant_resource_unknown`, sem executar nada.
771
+ */
772
+ async readResource(
773
+ resource: string,
774
+ id: string,
775
+ ctxBase: ContextBase,
776
+ options: ReadResourceOptions = {},
777
+ ): Promise<ResourceReadResult> {
778
+ const owner = this.assistantOwners.get(resource)
779
+ if (owner === undefined) {
780
+ return {
781
+ ok: false,
782
+ error: error({
783
+ code: 'runtime.assistant_resource_unknown',
784
+ category: 'validation',
785
+ // O tipo veio de fora: a mensagem é fixa, e o valor recebido fica em `meta`.
786
+ message: 'O tipo de recurso informado não tem action dona.',
787
+ meta: { resource },
788
+ }),
789
+ }
790
+ }
791
+ const result = await this.execute(owner.name, { id }, ctxBase)
792
+ if (!result.ok) return { ok: false, error: result.error, meta: result.meta }
793
+
794
+ const { assistant } = owner
795
+ let title: string | null
796
+ let omitted: AssistantOmission[]
797
+ let context: ResourceContext | undefined
798
+ try {
799
+ if (options.context === true) {
800
+ // A projeção já resolve o título; sem contexto pedido, basta o título, sem pagar o corte.
801
+ const projection = projectAssistantContext(assistant, result.data)
802
+ title = projection.title
803
+ omitted = projection.omitted
804
+ context = {
805
+ data: projection.data,
806
+ truncated: projection.truncated,
807
+ omitted: projection.omitted,
808
+ personalData: owner.personalData === true,
809
+ }
810
+ } else {
811
+ ;({ title, omitted } = resolveAssistantTitle(assistant, result.data))
812
+ }
813
+ } catch (thrown) {
814
+ // A action executou e foi auditada; o que falhou foi transformar a saída em dado ao modelo
815
+ // (por exemplo, `BigInt` ou referência circular, que o JSON não representa).
816
+ this.log.error('A saída da action dona não pôde ser projetada para o assistente.', {
817
+ code: 'runtime.assistant_projection_failed',
818
+ action: owner.name,
819
+ resource,
820
+ error: thrown instanceof Error ? thrown.message : String(thrown),
821
+ })
822
+ return {
823
+ ok: false,
824
+ error: error({
825
+ code: 'runtime.assistant_projection_failed',
826
+ category: 'internal',
827
+ message: 'Não foi possível preparar os dados deste recurso para o assistente.',
828
+ cause: thrown,
829
+ }),
830
+ meta: result.meta,
831
+ }
832
+ }
833
+ const missing = omitted.filter((entry) => entry.reason === 'missing').map((entry) => entry.path)
834
+ if (missing.length > 0) {
835
+ this.log.warn(
836
+ `A saída de "${owner.name}" não trouxe ${missing.map((path) => `\`${path}\``).join(', ')}, ` +
837
+ 'declarado no assistente, como texto ou valor esperado; o recurso foi lido sem esses campos.',
838
+ { code: 'runtime.assistant_path_missing', action: owner.name, resource, paths: missing },
839
+ )
840
+ }
841
+ return {
842
+ ok: true,
843
+ resource: { kind: resource, id, title, label: assistant.label, skill: assistant.skill },
844
+ ...(context !== undefined ? { context } : {}),
845
+ meta: result.meta,
846
+ }
847
+ }
848
+
849
+ /**
850
+ * As declarações de assistente registradas, com a action dona de cada uma (ADR 0053). Serve ao
851
+ * consumidor para montar rotas e telas a partir da declaração e para conferir as chaves de
852
+ * procedimento contra o registro que ele mantém.
853
+ */
854
+ assistants(): RegisteredAssistant[] {
855
+ return [...this.assistantOwners.values()].map((owner) => ({
856
+ action: owner.name,
857
+ ...publicAssistant(owner.assistant),
858
+ personalData: owner.personalData === true,
859
+ }))
860
+ }
861
+
729
862
  /**
730
863
  * Executa no worker um envelope criado por `execute()`. O consumer fornece
731
864
  * auth/can reidratados; identidade, provenance e trace vêm do envelope.
@@ -1026,6 +1159,9 @@ export class Runtime {
1026
1159
  metadata: {
1027
1160
  ...(effect !== undefined ? { effect } : {}),
1028
1161
  ...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
1162
+ ...(action.kind === 'view' && action.assistant !== undefined
1163
+ ? { assistant: publicAssistant(action.assistant) }
1164
+ : {}),
1029
1165
  dataProducts,
1030
1166
  dataProductLabels: Object.fromEntries(
1031
1167
  dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
package/src/core/types.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * 13. RuntimeConfig (§14)
22
22
  */
23
23
 
24
+ import type { AssistantDeclaration } from './assistant.ts'
24
25
  import type { SoftDeleteEntity } from './retention.ts'
25
26
 
26
27
  // =============================================================================
@@ -548,6 +549,30 @@ export interface AIConfig {
548
549
  requiresConfirmation?: boolean
549
550
  }
550
551
 
552
+ /**
553
+ * O assistente do recurso que uma action `view` lê (ADR 0053). A action que o declara é a action
554
+ * dona do tipo de recurso: é por ela que o recurso é lido e que o acesso a ele é conferido. A
555
+ * declaração é serializável e não carrega função, consulta nem regra de acesso.
556
+ */
557
+ export interface ActionAssistant {
558
+ /** O tipo de recurso de que a action é dona, em camelCase e único no runtime (`customer`). */
559
+ resource: string
560
+ /** A chave do procedimento que a IA segue ao tratar do recurso, em minúsculas com hífen. */
561
+ skill: string
562
+ /** O nome do assistente para pessoas (`Assistente do cliente`). */
563
+ label: string
564
+ /** O caminho, na saída, do campo que nomeia o recurso para pessoas (`name`). */
565
+ title: string
566
+ /**
567
+ * Os caminhos, na saída, dos campos que o host pode entregar ao modelo como dado do recurso. O
568
+ * ponto desce em objetos (`leads.totalCount`); um caminho que passa por uma lista se aplica a cada
569
+ * item.
570
+ */
571
+ context: readonly string[]
572
+ /** Teto, em caracteres, do JSON de `context`. Padrão de 4.000, mínimo de 1.000, máximo de 16.000. */
573
+ maxContextChars?: number
574
+ }
575
+
551
576
  export interface Example {
552
577
  name: string
553
578
  description?: string
@@ -943,6 +968,12 @@ export interface ViewAction<In, Out, ParsedIn = In> extends ActionBase<In, Out,
943
968
  /** Entidades relacionadas projetáveis. View simples não projeta nada — omita. */
944
969
  projection?: string[]
945
970
  expand?: Record<string, ExpandSpec>
971
+ /**
972
+ * Torna esta action a dona de um tipo de recurso e declara o assistente dele (ADR 0053). Exige
973
+ * `ai` habilitado, `personalData` declarado e uma entrada em que `id`, do tipo texto, é o único
974
+ * campo obrigatório. O runtime lê o recurso por `runtime.readResource`.
975
+ */
976
+ assistant?: ActionAssistant
946
977
  }
947
978
 
948
979
  /**
@@ -1341,6 +1372,9 @@ export interface AiTool {
1341
1372
  effect?: ActionEffect
1342
1373
  /** A action declara devolver dado pessoal; ausente quando não declarou. */
1343
1374
  personalData?: boolean
1375
+ /** O assistente do recurso de que a action é dona (ADR 0053), com o teto resolvido; ausente
1376
+ * quando não declara. */
1377
+ assistant?: AssistantDeclaration
1344
1378
  }
1345
1379
  }
1346
1380
 
package/src/mcp/index.ts CHANGED
@@ -58,6 +58,9 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
58
58
  ...(t.metadata?.personalData !== undefined
59
59
  ? { 'com.softize.opus/personal-data': t.metadata.personalData }
60
60
  : {}),
61
+ // O assistente do recurso de que a action é dona (ADR 0053): o host conhece o assistente
62
+ // pelo catálogo que já consulta.
63
+ ...(t.metadata?.assistant !== undefined ? { 'com.softize.opus/assistant': t.metadata.assistant } : {}),
61
64
  },
62
65
  })),
63
66
  }))
@@ -136,7 +136,7 @@ name, kind
136
136
  → public, requires, authorize, loads
137
137
  → handler
138
138
  → audit, confirm, invalidates, rateLimit, idempotency
139
- → ai, automatable, internal
139
+ → ai, assistant, automatable, internal
140
140
  ```
141
141
 
142
142
  Campos específicos de um kind, como `fields`, `paginate`, `columns`, `filters`, `expand`,
@@ -179,6 +179,102 @@ A autorização é fechada por padrão: sem `public: true`, a action exige usuá
179
179
  `authorize`, exige também a permissão declarada. `automatable: true` permite que um
180
180
  [schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
181
181
 
182
+ ## Declarar o assistente do recurso
183
+
184
+ Um produto pode oferecer, além do chat geral, uma conversa sobre um recurso específico: um cliente,
185
+ um relatório, um pedido. Essa conversa precisa ler o recurso com a permissão de quem conversa, seguir
186
+ o procedimento próprio daquele tipo de recurso e entregar ao modelo só os dados necessários. Os três
187
+ ficam declarados num lugar só: a action `view` que lê o recurso declara `assistant` e passa a ser a
188
+ **action dona** daquele tipo de recurso.
189
+
190
+ ```ts
191
+ export const customerViewContract = defineContract({
192
+ name: 'customer.view',
193
+ kind: 'view',
194
+ label: 'Ver cliente',
195
+ input: z.object({ id: z.string().uuid() }),
196
+ output: customerViewSchema,
197
+ personalData: true,
198
+ requires: 'customer.read',
199
+ authorize: (ctx) => ctx.can('customer.read'),
200
+ ai: true,
201
+ assistant: {
202
+ resource: 'customer',
203
+ skill: 'review-customer',
204
+ label: 'Assistente do cliente',
205
+ title: 'name',
206
+ context: ['kind', 'stage', 'sources', 'leads.totalCount', 'leads.latest.status'],
207
+ },
208
+ })
209
+ ```
210
+
211
+ | Campo | O que declara |
212
+ | --- | --- |
213
+ | `resource` | O tipo de recurso de que a action é dona, em camelCase. Cada tipo tem uma única action dona. |
214
+ | `skill` | A chave do procedimento que a IA segue ao tratar do recurso, em minúsculas com hífen. O Opus não conhece o procedimento; o produto o mantém e confere que a chave existe. |
215
+ | `label` | O nome do assistente para pessoas. Entra no inventário do `opus copy`. |
216
+ | `title` | O caminho, na saída, do campo que nomeia o recurso. |
217
+ | `context` | Os caminhos, na saída, dos campos que o host pode entregar ao modelo como dado do recurso. |
218
+ | `maxContextChars` | Opcional. O teto do dado entregue, em caracteres de JSON: 4.000 por padrão, de 1.000 a 16.000. |
219
+
220
+ A action dona precisa ser `view`, estar publicada para a IA (`ai`), declarar `personalData` e
221
+ receber só `{ id }`: o `id`, do tipo texto, é o único campo obrigatório da entrada, e outros campos,
222
+ se houver, têm valor padrão ou são opcionais. Os caminhos usam ponto para descer em objetos; um
223
+ caminho que passa por uma lista vale para cada item, e um campo intermediário `null` aparece como
224
+ `null`. Declare em `context` só o que orienta a conversa: o exemplo deixa fora contato, documento e
225
+ endereço, e o nome já vai como `title`. Um caminho que termina num objeto ou numa lista leva tudo o
226
+ que está abaixo dele, inclusive campos acrescentados depois à saída; para conferir num teste o que
227
+ cada caminho alcança, use `assistantContextLeaves`, de `@softize/opus/assistant-schema`, sobre o
228
+ schema de saída do manifest. O `title` precisa apontar para texto ou número.
229
+
230
+ O `opus check` cobra a forma da declaração (regra `assistant-declaration`), o `opus gen` recusa
231
+ `title`, caminho de `context` ou entrada que não conferem com os schemas, e o registro recusa duas
232
+ actions donas do mesmo recurso. O manifest, `runtime.aiTools()` e o MCP (`com.softize.opus/assistant`
233
+ em `_meta`) levam a declaração, e o manifest passa a mostrar também a publicação para a IA.
234
+
235
+ ### Ler o recurso pela action dona
236
+
237
+ O recurso de um assistente é lido, e o acesso a ele é conferido, somente pela action dona.
238
+ `runtime.readResource` executa a action com `{ id }` pelo mesmo `execute` de qualquer chamada; a
239
+ autorização, a auditoria e a marca de dado pessoal são as da action:
240
+
241
+ ```ts
242
+ const read = await runtime.readResource('customer', id, base, { context: true })
243
+ if (!read.ok) return notFound() // o erro da action, com a categoria dela
244
+ read.resource // { kind: 'customer', id, title: 'Maria Souza', label: 'Assistente do cliente', skill: 'review-customer' }
245
+ read.context // { data, truncated, omitted, personalData: true }
246
+ ```
247
+
248
+ Sem `{ context: true }`, a leitura devolve só o descritor, que basta para conferir o acesso e
249
+ mostrar o nome. Um tipo sem action dona devolve `runtime.assistant_resource_unknown` sem executar
250
+ nada. `runtime.assistants()` lista as declarações com a action dona de cada uma, para o produto
251
+ montar rotas e telas sem lista paralela e para conferir, num teste, que cada `skill` existe no
252
+ registro de procedimentos dele. O Opus não enxerga as rotas HTTP que o produto registra por conta
253
+ própria: um teste do produto deve garantir que nenhuma rota confere acesso a recurso sem passar por
254
+ uma action.
255
+
256
+ ### O dado que vai ao modelo
257
+
258
+ `context.data` traz só os caminhos declarados, com a estrutura da saída. Quando o JSON passa do
259
+ teto, o corte acontece por etapas, até caber, e fica registrado em `omitted`:
260
+
261
+ 1. sai o último item da maior lista, repetidamente;
262
+ 2. com as listas vazias, o texto mais longo é encurtado pela metade, até 200 caracteres;
263
+ 3. por fim, saem campos inteiros, do último declarado para o primeiro.
264
+
265
+ Com qualquer corte, `truncated` vale `true`. O título fica fora do teto e é encurtado a 200
266
+ caracteres. Um caminho que falta na saída em execução é omitido com o motivo `missing` e gera um
267
+ aviso no log, sem falhar a leitura. Quem recebe a saída por outro canal, como o MCP, aplica a mesma
268
+ projeção com `projectAssistantContext(assistant, output)`.
269
+
270
+ Entregue o dado ao modelo como bloco delimitado, numa mensagem de dado, nunca no prompt de sistema.
271
+ `resourceDataBlock`, de `@softize/opus/ai`, monta o bloco com o tipo, o id, o título, os dados e,
272
+ quando houve corte ou ausência, `truncated` e `omitted`, e escapa dentro dos valores o que poderia
273
+ fechar o delimitador. Quando o modelo chama a action dona como ferramenta, recebe a saída completa
274
+ do contrato, como em qualquer chamada: a projeção governa só o que o host entrega por iniciativa
275
+ própria. Cada execução de uma action com dado pessoal é uma exposição registrada, então reaproveite
276
+ a leitura que já conferiu o acesso em vez de executar a action de novo só para obter o contexto.
277
+
182
278
  ## Prototipar com dados simulados
183
279
 
184
280
  Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
@@ -86,8 +86,10 @@ const { text } = await ctx.ai!.run('Quais notas a Empresa X emitiu em junho?')
86
86
  usado na execução e na auditoria; `title` traz a `label` legível da action, quando existe;
87
87
  `description` vem de `ai.description` (ou da `description` da action) e `inputSchema`, do input.
88
88
  `metadata.dataProducts` lista os [Produtos de Dados](/docs/data-products) relacionados e
89
- `metadata.dataProductLabels` associa cada identificador ao seu nome legível. Esses metadados servem ao host: não entram no
90
- prompt e não concedem acesso.
89
+ `metadata.dataProductLabels` associa cada identificador ao seu nome legível. Na action dona de um
90
+ tipo de recurso, `metadata.assistant` traz o [assistente declarado](/docs/actions): o recurso, a
91
+ chave do procedimento, o nome, o campo do título, os campos do dado ao modelo e o teto. Esses
92
+ metadados servem ao host: não entram no prompt e não concedem acesso.
91
93
 
92
94
  O agente só executa nomes que `aiTools()` anunciou; uma chamada a outra action volta como erro
93
95
  para o modelo. Actions marcadas com `ai: { destructive: true }` ou `ai: { requiresConfirmation: true }`
@@ -21,7 +21,8 @@ opus seed check # bindings, dependências, ciclos e scripts paralelos de s
21
21
  ```
22
22
 
23
23
  `opus check` lê o código-fonte sem executá-lo e aplica regras em quatro grupos: actions
24
- (`action-name`, `kind`, `field-order`, `export`, `requires-sem-authorize`, `ignored-by-kind`), UI
24
+ (`action-name`, `kind`, `field-order`, `export`, `requires-sem-authorize`, `ignored-by-kind` e
25
+ `assistant-declaration`, que cobra a forma do [assistente do recurso](/docs/actions)), UI
25
26
  (`ui-structure`, `ui-structure-split`, `ui-semantic-api`, `removed-ui-token`, `unpaired-ui-surface`),
26
27
  [Produtos de Dados](/docs/data-products) (`data-product-export`, `-id`, `-duplicate`, `-version`,
27
28
  `-interfaces`, `-interface`, `-entities` e `-entity`, todas com o prefixo `data-product`) e
@@ -60,7 +60,9 @@ Quando a action declara o que faz com os dados, a tool traz também as anotaçõ
60
60
  `readOnlyHint` vem de `effect`. Numa action que escreve, `destructiveHint` vai sempre explícito, a
61
61
  partir de `ai.destructive`, porque o MCP trata a ausência numa escrita como destrutiva.
62
62
  Em `_meta`, `com.softize.opus/effect` repete o efeito e `com.softize.opus/personal-data` diz se a
63
- resposta contém dado pessoal. Veja [Actions](/docs/actions).
63
+ resposta contém dado pessoal. Na action dona de um tipo de recurso, `com.softize.opus/assistant`
64
+ traz o assistente declarado, para o host conhecê-lo pelo catálogo que já consulta e aplicar à saída
65
+ a mesma projeção com `projectAssistantContext`. Veja [Actions](/docs/actions).
64
66
 
65
67
  `CallTool` recusa com erro qualquer nome fora dessa lista; os demais seguem por `runtime.execute`.
66
68
  A exposição declarada em `ai` decide o que o cliente MCP pode executar, e a autorização do contexto