@zihin/agent-client 0.1.0 → 0.4.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.
package/README.md CHANGED
@@ -75,14 +75,93 @@ const exec = await zihin.getExecution({ triggerId, executionId });
75
75
 
76
76
  > O modo `sync` do trigger (`mode: 'sync'`) responde 200 + JSON na hora (`dispatchResult.agentResponse`).
77
77
 
78
+ ### Payload estruturado (não só texto)
79
+
80
+ O caminho direto (`invokeAgent`) só aceita **texto** — o endpoint `/stream` do BE não tem campo de
81
+ payload estruturado. Para disparar um agente a partir de um JSON rico, use triggers: o payload vai
82
+ inteiro no `dispatchAgent` e o `queryExtraction` define como ele vira a mensagem do agente.
83
+
84
+ ```ts
85
+ // template: compõe a mensagem a partir de campos aninhados do payload
86
+ await zihin.findOrCreateTrigger({
87
+ agentId,
88
+ name: 'Pedidos',
89
+ mode: 'sync',
90
+ queryExtraction: { mode: 'template', template: 'Pedido {{payload.order.id}}: {{payload.order.note}}' },
91
+ });
92
+
93
+ // field (default): um campo top-level do payload
94
+ // queryExtraction: { mode: 'field', field: 'texto' }
95
+ // full_body: fallback do BE → payload.message || payload.query || JSON.stringify(payload)
96
+ // queryExtraction: { mode: 'full_body' }
97
+
98
+ await zihin.dispatchAgent({ triggerId, payload: { order: { id: 42, note: 'urgente' }, cliente: {...} } });
99
+ ```
100
+
101
+ ⚠️ O que chega ao agente é **a string extraída**, não o payload inteiro: com `field`/`template`, os
102
+ campos que você não referenciou ficam de fora. Para levar tudo, use `full_body` — e note que ele
103
+ só serializa o payload completo **se não houver** um campo `message`/`query` no topo (nesse caso o
104
+ BE usa esse campo). Precisa de campos extras como contexto estruturado? Isso é `context_mapping`
105
+ no trigger, ainda não exposto pelo núcleo — configure direto no BE.
106
+
78
107
  ### Ciclo de vida do trigger
79
108
 
80
109
  ```ts
81
110
  await zihin.listTriggers({ agentId }); // lista (filtros: agentId/triggerType/enabledOnly)
82
111
  await zihin.getTrigger({ triggerId }); // detalhe (inclui webhookUrl)
112
+ await zihin.updateTrigger({ triggerId, enabled: false }); // PATCH parcial
113
+ await zihin.updateTrigger({ triggerId, queryExtraction: { mode: 'full_body' } }); // GET+merge+PATCH
83
114
  await zihin.deleteTrigger({ triggerId }); // remove (requer key admin/owner; editor → 403)
84
115
  ```
85
116
 
117
+ > `updateTrigger` faz GET + merge + PATCH quando você altera config (o BE substitui
118
+ > `trigger_config` inteiro). Há uma janela entre o GET e o PATCH em que uma escrita concorrente
119
+ > pode ser sobrescrita.
120
+
121
+ ## Uso — delegação assíncrona durável (tasks)
122
+
123
+ Para runs que estouram o teto do host (o passo do Zapier morre em ~30s) ou que não podem depender
124
+ da conexão viva. A task é persistida no BE, executada fora do request e o resultado notificado.
125
+ Exige key **owner/admin** para delegar e cancelar; editor só lê.
126
+
127
+ ```ts
128
+ const task = await zihin.delegateTask({
129
+ agentId,
130
+ instruction: 'Gere o relatório mensal e envie por e-mail',
131
+ title: 'Relatório de agosto',
132
+ payload: { mes: '2026-08' }, // objeto, ≤ 64KB — validado localmente
133
+ deadlineAt: new Date(Date.now() + 3600e3),
134
+ });
135
+
136
+ // polling: pare quando `terminal` for true — qualquer status desconhecido conta como terminal
137
+ let dossie;
138
+ do {
139
+ await new Promise((r) => setTimeout(r, 2000));
140
+ dossie = await zihin.getTask({ taskId: task.id });
141
+ } while (!dossie.task.terminal);
142
+
143
+ console.log(dossie.task.status, dossie.executions.at(-1)?.totalCostUsd);
144
+ await zihin.cancelTask({ taskId: task.id, reason: 'mudou o plano' }); // 409 se já encerrou
145
+ ```
146
+
147
+ `listTasks({ live: true })` devolve o board (`items` + `total`, este vindo de `meta`).
148
+
149
+ ## O texto que volta — e o que NÃO é resposta
150
+
151
+ `AgentResult.contentSource` diz de onde veio o `content`:
152
+
153
+ - **`'response'`** — o evento final do BE, que no `/stream` é sempre o modo `final` (só as mensagens
154
+ sem `tool_calls`). **É o texto seguro para publicar.**
155
+ - **`'tokens'`** — o stream morreu antes do evento final e sobrou só a concatenação crua dos deltas,
156
+ **com a deliberação interna do modelo**. Foi exatamente isso que vazou para um lead real via
157
+ WhatsApp e que o BE parou de entregar. Trate como parcial de diagnóstico; não mande para fora
158
+ sem revisar.
159
+
160
+ Um `content` vazio com `contentSource: 'response'` é decisão do BE, não lacuna — o núcleo **não**
161
+ preenche com tokens. E quando o BE fecha o stream com `event: error` (turno morto no deadline, por
162
+ exemplo), o núcleo **lança** (`kind: 'timeout'` para `TURN_TIMEOUT`, com `code` e `requestId`) em vez
163
+ de devolver o parcial.
164
+
86
165
  ## Dropdown de agentes
87
166
 
88
167
  ```ts
@@ -93,8 +172,8 @@ const agents = await zihin.listAgents(); // GET /api/v2/agents
93
172
  ## Permissões da API key
94
173
 
95
174
  - `invokeAgent` / `streamAgent` — qualquer role.
96
- - `createTrigger` / `getExecution` — exigem key **admin** ou **editor** (default é admin). Keys
97
- `member` recebem `ZihinAgentError` com `kind: 'auth'`.
175
+ - `createTrigger` / `updateTrigger` / `getExecution` — exigem key **admin** ou **editor** (default é
176
+ admin). Keys `member` recebem `ZihinAgentError` com `kind: 'auth'`.
98
177
 
99
178
  ## Erros
100
179
 
@@ -105,10 +184,33 @@ suporte correlacionar). Erros de rede carregam a causa-raiz (`ECONNREFUSED`/`ENO
105
184
  mensagem. As mensagens redigem **só segredos** (key/Bearer/stack) — `request_id`/`trace_id` são
106
185
  preservados. Cancele com `AbortSignal` via `params.signal`.
107
186
 
187
+ ### Bloqueio comercial não é credencial errada
188
+
189
+ Trial vencido, assinatura inativa e quota esgotada chegam do BE como 403/429 e, mapeados só pelo
190
+ status, virariam "troque a key" ou "tente de novo". Têm `kind` próprio e **nunca são retentados**:
191
+
192
+ | `kind` | HTTP | O que fazer |
193
+ |---|---|---|
194
+ | `trial_expired` | 403 | Fazer upgrade do plano — a key está certa |
195
+ | `no_subscription` | 429 | Ativar uma assinatura — a key está certa |
196
+ | `quota_exceeded` | 429 | Esperar o ciclo ou subir o plano — retentar não ajuda |
197
+
198
+ `isCommercialBlock(body)` é exportado para quem inspeciona respostas por conta própria. O 403 comum
199
+ (`kind: 'auth'`) significa que o **papel** da key não permite a ação — e o papel exigido varia por
200
+ rota (editor cria trigger, mas não delega task), então a mensagem não enumera papéis.
201
+
202
+ `ZihinAgentError.executionId` vem preenchido quando o BE o entrega (sucesso e erro) — é o handle para
203
+ correlacionar com o console.
204
+
108
205
  ## Robustez (timeout / retry / limites)
109
206
 
110
- - **Timeout cobre tudo** — headers e o corpo/stream inteiro. `timeoutMs` (default 120s, igual ao cap
111
- do BE) é o tempo máximo total da chamada; `AbortSignal` do chamador funciona durante todo o stream.
207
+ - **Timeout cobre tudo** — headers e o corpo/stream inteiro. Os defaults são **por operação**
208
+ (`OPERATION_TIMEOUT_MS`): stream 200s, dispatch de trigger 260s, GETs curtos 35s. Cada um fica
209
+ ACIMA do orçamento do BE para aquela rota (SSE 180s, turno async 240s, timeout global 30s), para
210
+ que o desfecho do servidor chegue como erro estruturado em vez de abort cego do cliente.
211
+ `timeoutMs` na config sobrepõe todos; `options.timeoutMs` por chamada vai ao BE como
212
+ `timeout_ms` e o deadline local fica um pouco acima dele. Retentativas NÃO encolhem o deadline
213
+ da tentativa seguinte — o que fica limitado é o tempo gasto com retries.
112
214
  - **Retry sem duplicar efeito** — GET (idempotente) retenta em rede/429/5xx; POST com efeito
113
215
  (`invokeAgent`/`dispatchAgent`/`createTrigger`) retenta **só em 429/503** e **nunca em erro de
114
216
  rede** (o request pode ter sido processado). Backoff exponencial 1s→10s com jitter.
@@ -123,11 +225,17 @@ preservados. Cancele com `AbortSignal` via `params.signal`.
123
225
  ### Observabilidade e portabilidade
124
226
 
125
227
  - **Hooks** (opt-in, sem `console`): `onRetry(info)` (a cada retentativa, com `endpoint`/`attempt`/
126
- `delayMs`/`reason`/`status`) e `onError(err)`.
228
+ `delayMs`/`reason`/`status`) e `onError(err)` — chamado quando uma operação falha em definitivo
229
+ (inclusive `aborted`; filtre por `err.kind`). Exceção lançada dentro do hook é engolida e nunca
230
+ mascara o erro original.
127
231
  - **`fetch` injetável** — `fetch?` na config; por padrão usa `globalThis.fetch` (Node ≥18). Um
128
232
  preflight dá erro `config` claro se não houver fetch.
129
233
  - **Portável** — sem `Buffer`; `node:crypto` só é carregado se você usar `signRequest`. Roda em Node;
130
- para edge/serverless, injete `fetch` se necessário.
234
+ para edge/serverless, injete `fetch` se necessário. O pacote publica **CJS e ESM** (`exports` map):
235
+ `require()` e `import` funcionam. Duas ressalvas: no build ESM, `signRequest` precisa de Node
236
+ >=20.16 (`process.getBuiltinModule`) e sem isso lança `ZihinAgentError` com `kind: 'config'`; e
237
+ carregar **as duas formas no mesmo processo** cria duas classes `ZihinAgentError` distintas, o que
238
+ faz `instanceof` falhar — padronize `require` **ou** `import` para o núcleo na sua árvore de deps.
131
239
 
132
240
  ```ts
133
241
  const zihin = createClient({
@@ -140,8 +248,14 @@ const zihin = createClient({
140
248
  });
141
249
  ```
142
250
 
143
- > SSRF: a validação de base/callback URL é por nome de host. Com `allowCustomBaseUrl: true` um host
144
- > que resolva para IP privado (DNS rebinding) passa — mantenha o whitelist em ambiente sensível.
251
+ > SSRF: a validação de base/callback/attachment URL é por **nome de host** — cobre IPv4 privado
252
+ > (inclusive octal/hex, normalizados pela URL), IPv6 privado **por faixa de bits** (`fc00::/7`,
253
+ > `fe80::/10`, `fec0::/10`, loopback e IPv4 embutido `::ffff:`/`::`), FQDN com ponto final e os
254
+ > hosts de metadata de nuvem.
255
+ >
256
+ > **Limite importante:** é validação por nome, não por IP resolvido. Com `allowCustomBaseUrl: true`,
257
+ > um host que **resolva** para IP privado (DNS rebinding) passa. Isto não substitui bloqueio de
258
+ > egresso na borda de rede — mantenha o whitelist (default) em ambiente sensível.
145
259
 
146
260
  ## Segurança
147
261