@volund-ia/sdk 0.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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.cjs +537 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +356 -0
- package/dist/index.d.mts +356 -0
- package/dist/index.mjs +524 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Volund
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @volund-ia/sdk
|
|
2
|
+
|
|
3
|
+
Cliente TypeScript para rodar agentes do **Volund OS** pelo seu próprio código e
|
|
4
|
+
receber, em tempo real (streaming), tudo que o agente faz — raciocínio, chamadas
|
|
5
|
+
de ferramenta e a resposta token a token.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @volund-ia/sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> Requer Node ≥ 18 (usa o `fetch` nativo). Funciona também em Deno, Bun, Workers
|
|
12
|
+
> e no browser (parser SSE 100% web-standard).
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { VolundOS } from "@volund-ia/sdk";
|
|
18
|
+
|
|
19
|
+
const volund = new VolundOS({ apiKey: process.env.VOLUND_API_KEY! });
|
|
20
|
+
|
|
21
|
+
const run = await volund.agents.run({
|
|
22
|
+
agentId: "agt_123",
|
|
23
|
+
input: "Pesquise os 3 maiores concorrentes da empresa X e resuma.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Passo a passo conforme acontece:
|
|
27
|
+
for await (const event of run.stream()) {
|
|
28
|
+
if (event.type === "assistant_text_delta") process.stdout.write(event.delta);
|
|
29
|
+
if (event.type === "tool_call") console.log("→ usou:", event.tool_name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Ou só o resultado final:
|
|
33
|
+
const run2 = await volund.agents.run({ agentId: "agt_123", input: "Oi" });
|
|
34
|
+
const { output, usage } = await run2.result();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Continuar uma conversa (mesma thread):
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
const next = await volund.agents.continue({ runId: run.id, input: "E o 4º?" });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## A DX
|
|
44
|
+
|
|
45
|
+
Espelha o Cursor SDK (`Agent.create()` → `agent.send()` → `run.stream()`):
|
|
46
|
+
`new VolundOS()` → `agents.run()` → `run.stream()` / `run.result()` /
|
|
47
|
+
`run.cancel()`.
|
|
48
|
+
|
|
49
|
+
## Eventos (`VolundEvent`)
|
|
50
|
+
|
|
51
|
+
Stream tipado por união discriminada — faça narrowing por `event.type`:
|
|
52
|
+
|
|
53
|
+
| `type` | Campos |
|
|
54
|
+
| ----------------------- | ------------------------------------------------- |
|
|
55
|
+
| `run_started` | `protocol`, `run_id`, `agent_id` |
|
|
56
|
+
| `thinking_delta` | `delta` (raciocínio, streaming) |
|
|
57
|
+
| `assistant_text_delta` | `delta` (resposta, streaming) |
|
|
58
|
+
| `tool_call` | `tool_call_id`, `tool_name`, `input` |
|
|
59
|
+
| `tool_result` | `tool_call_id`, `output`, `is_error?` |
|
|
60
|
+
| `awaiting_input` | `request_id`, `kind: "vault"` (HITL — fecha o stream) |
|
|
61
|
+
| `run_finished` | `status`, `output`, `usage`, `error?` |
|
|
62
|
+
|
|
63
|
+
O contrato é **snake_case no fio** (consistente com a API v1 e o ecossistema
|
|
64
|
+
Anthropic/Cursor) e versionado por `SCHEMA_VERSION` (`protocol` no `run_started`).
|
|
65
|
+
|
|
66
|
+
## Erros
|
|
67
|
+
|
|
68
|
+
Todos herdam de `VolundError` (tem `.code` e `.status`). Roteie por `instanceof`:
|
|
69
|
+
|
|
70
|
+
| Classe | Quando |
|
|
71
|
+
| --------------------------- | --------------------------------------- |
|
|
72
|
+
| `VolundAuthError` | 401 — chave ausente/inválida |
|
|
73
|
+
| `VolundForbiddenError` | 403 — sem acesso ao agente |
|
|
74
|
+
| `VolundNotFoundError` | 404 — agente/run inexistente |
|
|
75
|
+
| `VolundRunBusyError` | 409 — já há run ativo na thread |
|
|
76
|
+
| `VolundRunFailedError` | `run.result()` quando o run falha |
|
|
77
|
+
| `VolundAwaitingInputError` | `run.result()` quando pausa p/ vault |
|
|
78
|
+
|
|
79
|
+
## Notas
|
|
80
|
+
|
|
81
|
+
- **`stream()` é consumível uma única vez** (é um stream de rede). Não combine
|
|
82
|
+
`stream()` e `result()` no mesmo `Run`.
|
|
83
|
+
- `run.cancel()` aborta a conexão — o servidor encerra a sandbox.
|
|
84
|
+
- `execution: "local"` (rodar no `cwd` do dev, estilo Cursor) chega na **V2**; o
|
|
85
|
+
tipo já existe, mas a V1 só roda na nuvem.
|
|
86
|
+
|
|
87
|
+
## Testar contra um preview da Vercel (modo intermediário)
|
|
88
|
+
|
|
89
|
+
Antes do endpoint de produção, dá pra apontar o SDK pro deployment de preview do
|
|
90
|
+
PR:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
VOLUND_API_KEY=vos_live_... \
|
|
94
|
+
VOLUND_AGENT_ID=agt_... \
|
|
95
|
+
VOLUND_BASE_URL=https://seu-preview.vercel.app \
|
|
96
|
+
npm run example
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Se o preview estiver com **Deployment Protection** ligada, passe o token de
|
|
100
|
+
*Protection Bypass for Automation* — ele vira um header via `defaultHeaders`:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
VERCEL_BYPASS=<secret> ...demais envs... npm run example
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
new VolundOS({
|
|
108
|
+
apiKey,
|
|
109
|
+
baseUrl: "https://seu-preview.vercel.app",
|
|
110
|
+
defaultHeaders: { "x-vercel-protection-bypass": process.env.VERCEL_BYPASS! },
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Instalar antes da publicação no NPM (beta)
|
|
115
|
+
|
|
116
|
+
Enquanto `@volund-ia/sdk` não está publicado:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm install anaraque-l/volund-sdk # do GitHub (builda no install via `prepare`)
|
|
120
|
+
# ou um tarball:
|
|
121
|
+
npm pack && npm install ./volund-sdk-0.2.0.tgz
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Desenvolvimento
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm install
|
|
128
|
+
npm test # testes do parser SSE (vitest)
|
|
129
|
+
npm run typecheck
|
|
130
|
+
npm run build # tsdown → ESM + CJS + .d.ts
|
|
131
|
+
npm run check:protocol # garante o contrato em sincronia com o volund-os
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
O contrato de eventos é **vendorado** de `volund-os` em `src/protocol/events.ts`
|
|
135
|
+
— ver [`src/protocol/README.md`](src/protocol/README.md). Atualize só via
|
|
136
|
+
`npm run sync:protocol`.
|
|
137
|
+
|
|
138
|
+
## Licença
|
|
139
|
+
|
|
140
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let eventsource_parser = require("eventsource-parser");
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
/** Erro base de todo o SDK. */
|
|
5
|
+
var VolundError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, opts) {
|
|
9
|
+
super(message, { cause: opts.cause });
|
|
10
|
+
this.name = "VolundError";
|
|
11
|
+
this.code = opts.code;
|
|
12
|
+
this.status = opts.status;
|
|
13
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/** 401 — chave ausente ou inválida. */
|
|
17
|
+
var VolundAuthError = class extends VolundError {
|
|
18
|
+
constructor(message, code = "invalid_api_key", cause) {
|
|
19
|
+
super(message, {
|
|
20
|
+
code,
|
|
21
|
+
status: 401,
|
|
22
|
+
cause
|
|
23
|
+
});
|
|
24
|
+
this.name = "VolundAuthError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/** 403 — a chave não tem acesso a este agente/run. */
|
|
28
|
+
var VolundForbiddenError = class extends VolundError {
|
|
29
|
+
constructor(message, cause) {
|
|
30
|
+
super(message, {
|
|
31
|
+
code: "forbidden",
|
|
32
|
+
status: 403,
|
|
33
|
+
cause
|
|
34
|
+
});
|
|
35
|
+
this.name = "VolundForbiddenError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** 404 — agente ou run inexistente. */
|
|
39
|
+
var VolundNotFoundError = class extends VolundError {
|
|
40
|
+
constructor(message, code = "agent_not_found", cause) {
|
|
41
|
+
super(message, {
|
|
42
|
+
code,
|
|
43
|
+
status: 404,
|
|
44
|
+
cause
|
|
45
|
+
});
|
|
46
|
+
this.name = "VolundNotFoundError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** 409 — já existe um run ativo na thread (só na continuação). */
|
|
50
|
+
var VolundRunBusyError = class extends VolundError {
|
|
51
|
+
constructor(message, cause) {
|
|
52
|
+
super(message, {
|
|
53
|
+
code: "run_busy",
|
|
54
|
+
status: 409,
|
|
55
|
+
cause
|
|
56
|
+
});
|
|
57
|
+
this.name = "VolundRunBusyError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** O run terminou com `status: "failed"`. Lançado por `run.result()`. */
|
|
61
|
+
var VolundRunFailedError = class extends VolundError {
|
|
62
|
+
constructor(message, cause) {
|
|
63
|
+
super(message, {
|
|
64
|
+
code: "run_failed",
|
|
65
|
+
cause
|
|
66
|
+
});
|
|
67
|
+
this.name = "VolundRunFailedError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* O run pausou esperando ação humana (HITL — na V1, preenchimento de cofre).
|
|
72
|
+
* `run.result()` lança isto porque o stream termina sem `run_finished`. Quem
|
|
73
|
+
* usa `run.stream()` recebe o evento `awaiting_input` normalmente, sem exceção.
|
|
74
|
+
*/
|
|
75
|
+
var VolundAwaitingInputError = class extends VolundError {
|
|
76
|
+
requestId;
|
|
77
|
+
kind;
|
|
78
|
+
constructor(requestId, kind) {
|
|
79
|
+
super(`Run pausou aguardando entrada do tipo "${kind}" (request ${requestId}).`, { code: "awaiting_input" });
|
|
80
|
+
this.name = "VolundAwaitingInputError";
|
|
81
|
+
this.requestId = requestId;
|
|
82
|
+
this.kind = kind;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** Constrói a subclasse certa a partir de uma resposta de erro da API. */
|
|
86
|
+
function errorFromApiResponse(status, body) {
|
|
87
|
+
const code = body.error ?? "internal_error";
|
|
88
|
+
const message = body.message ?? `Requisição falhou (HTTP ${status}).`;
|
|
89
|
+
switch (code) {
|
|
90
|
+
case "missing_api_key":
|
|
91
|
+
case "invalid_api_key": return new VolundAuthError(message, code);
|
|
92
|
+
case "forbidden": return new VolundForbiddenError(message);
|
|
93
|
+
case "agent_not_found":
|
|
94
|
+
case "run_not_found": return new VolundNotFoundError(message, code);
|
|
95
|
+
case "run_busy": return new VolundRunBusyError(message);
|
|
96
|
+
default: return new VolundError(message, {
|
|
97
|
+
code,
|
|
98
|
+
status
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Base do backoff exponencial (ms): 300, 600, 1200... */
|
|
103
|
+
const RETRY_BASE_MS = 300;
|
|
104
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
105
|
+
/**
|
|
106
|
+
* Combina o sinal externo (cancel do usuário) com um timeout. O fetch recebe o
|
|
107
|
+
* sinal combinado. `clearTimer()` desarma o timeout (chame ao receber a resposta,
|
|
108
|
+
* p/ o timer não abortar o stream em andamento); a ligação com o sinal externo
|
|
109
|
+
* permanece, então `run.cancel()` segue funcionando durante todo o stream.
|
|
110
|
+
*/
|
|
111
|
+
function linkAbort(external, timeoutMs) {
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
let timedOut = false;
|
|
114
|
+
const onExternalAbort = () => controller.abort();
|
|
115
|
+
if (external) if (external.aborted) controller.abort();
|
|
116
|
+
else external.addEventListener("abort", onExternalAbort, { once: true });
|
|
117
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
118
|
+
timedOut = true;
|
|
119
|
+
controller.abort();
|
|
120
|
+
}, timeoutMs) : void 0;
|
|
121
|
+
return {
|
|
122
|
+
signal: controller.signal,
|
|
123
|
+
/** Foi o timeout (e não o cancel do usuário) que abortou? */
|
|
124
|
+
timedOut: () => timedOut,
|
|
125
|
+
/** Desarma o timeout MANTENDO a ligação com o sinal externo. Use no sucesso,
|
|
126
|
+
* p/ `run.cancel()` seguir abortando o stream em andamento. */
|
|
127
|
+
clearTimer: () => {
|
|
128
|
+
if (timer) clearTimeout(timer);
|
|
129
|
+
},
|
|
130
|
+
/** Desarma o timer E solta o listener do sinal externo. Use em tentativas
|
|
131
|
+
* abandonadas (erro/retry) p/ não vazar listeners no signal do usuário. */
|
|
132
|
+
dispose: () => {
|
|
133
|
+
if (timer) clearTimeout(timer);
|
|
134
|
+
if (external) external.removeEventListener("abort", onExternalAbort);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Faz POST num endpoint `/stream` e devolve a `Response` SSE crua. Lança a
|
|
140
|
+
* subclasse de `VolundError` apropriada se a resposta for um erro. Aplica timeout
|
|
141
|
+
* pré-stream e retry (rede/5xx) conforme a config.
|
|
142
|
+
*/
|
|
143
|
+
async function postStream(cfg, path, body, signal) {
|
|
144
|
+
const url = `${cfg.baseUrl.replace(/\/+$/, "")}${path}`;
|
|
145
|
+
const timeoutMs = cfg.timeoutMs ?? 6e4;
|
|
146
|
+
const maxRetries = cfg.maxRetries ?? 0;
|
|
147
|
+
const sleep = cfg.sleep ?? defaultSleep;
|
|
148
|
+
const payload = JSON.stringify(body);
|
|
149
|
+
let lastError;
|
|
150
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
151
|
+
if (signal?.aborted) throw new VolundError(`Requisição a ${path} cancelada.`, {
|
|
152
|
+
code: "network_error",
|
|
153
|
+
cause: signal.reason
|
|
154
|
+
});
|
|
155
|
+
const link = linkAbort(signal, timeoutMs);
|
|
156
|
+
let res;
|
|
157
|
+
try {
|
|
158
|
+
res = await cfg.fetch(url, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
...cfg.defaultHeaders,
|
|
162
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
Accept: "text/event-stream"
|
|
165
|
+
},
|
|
166
|
+
body: payload,
|
|
167
|
+
signal: link.signal
|
|
168
|
+
});
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
link.dispose();
|
|
171
|
+
if (link.timedOut()) lastError = new VolundError(`Timeout (${timeoutMs}ms) esperando resposta de ${path}.`, {
|
|
172
|
+
code: "timeout",
|
|
173
|
+
cause
|
|
174
|
+
});
|
|
175
|
+
else if (signal?.aborted) throw new VolundError(`Requisição a ${path} cancelada.`, {
|
|
176
|
+
code: "network_error",
|
|
177
|
+
cause
|
|
178
|
+
});
|
|
179
|
+
else lastError = new VolundError(`Falha de rede ao chamar ${path}.`, {
|
|
180
|
+
code: "network_error",
|
|
181
|
+
cause
|
|
182
|
+
});
|
|
183
|
+
if (attempt < maxRetries) {
|
|
184
|
+
await sleep(RETRY_BASE_MS * 2 ** attempt);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
throw lastError;
|
|
188
|
+
}
|
|
189
|
+
link.clearTimer();
|
|
190
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
191
|
+
if (res.ok && contentType.includes("text/event-stream")) return res;
|
|
192
|
+
link.dispose();
|
|
193
|
+
let errBody = {};
|
|
194
|
+
try {
|
|
195
|
+
errBody = await res.json();
|
|
196
|
+
} catch {
|
|
197
|
+
errBody = { message: `Resposta inesperada (HTTP ${res.status}).` };
|
|
198
|
+
}
|
|
199
|
+
const mapped = errorFromApiResponse(res.status, errBody);
|
|
200
|
+
if (res.status >= 500 && attempt < maxRetries) {
|
|
201
|
+
lastError = mapped;
|
|
202
|
+
await sleep(RETRY_BASE_MS * 2 ** attempt);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
throw mapped;
|
|
206
|
+
}
|
|
207
|
+
throw lastError ?? new VolundError(`Falha ao chamar ${path}.`, { code: "network_error" });
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/sse.ts
|
|
211
|
+
/**
|
|
212
|
+
* Parser SSE → `VolundEvent`. Camada fina sobre `eventsource-parser`, o mesmo
|
|
213
|
+
* parser usado pelo Vercel AI SDK e pelo SDK da OpenAI. Ele resolve, de graça,
|
|
214
|
+
* as três armadilhas do wire do Volund OS (ver `sse-adapter.ts` no servidor):
|
|
215
|
+
*
|
|
216
|
+
* 1. Heartbeat `: ping\n\n` — linhas de comentário são ignoradas pelo parser.
|
|
217
|
+
* 2. Campo `id: <n>` por frame — exposto como `event.id` (reservado p/
|
|
218
|
+
* reconexão na V2); a V1 não o usa.
|
|
219
|
+
* 3. `data:` multi-linha / partido entre chunks — o parser bufferiza e
|
|
220
|
+
* concatena conforme a spec SSE.
|
|
221
|
+
*
|
|
222
|
+
* Cada `event.data` é um JSON de um único `VolundEvent`. JSON inválido ou tipo
|
|
223
|
+
* desconhecido é IGNORADO (regra de ouro [D4]: clientes ignoram o que não
|
|
224
|
+
* conhecem — permite minor bumps sem quebrar).
|
|
225
|
+
*
|
|
226
|
+
* Usamos a API de callback (`createParser`) + um reader manual em vez do
|
|
227
|
+
* `EventSourceParserStream` para evitar o atrito de variância de tipos entre
|
|
228
|
+
* `TextDecoderStream` e `ReadableStream<Uint8Array>` no `lib.dom`.
|
|
229
|
+
*/
|
|
230
|
+
const KNOWN_TYPES = /* @__PURE__ */ new Set([
|
|
231
|
+
"run_started",
|
|
232
|
+
"thinking_delta",
|
|
233
|
+
"assistant_text_delta",
|
|
234
|
+
"tool_call",
|
|
235
|
+
"tool_result",
|
|
236
|
+
"awaiting_input",
|
|
237
|
+
"run_finished"
|
|
238
|
+
]);
|
|
239
|
+
function isVolundEvent(value) {
|
|
240
|
+
return typeof value === "object" && value !== null && typeof value.type === "string" && KNOWN_TYPES.has(value.type);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Transforma o corpo de uma resposta `text/event-stream` numa sequência de
|
|
244
|
+
* `VolundEvent`. Web-standard: roda em Node ≥18, Deno, Bun, Workers e browser.
|
|
245
|
+
*/
|
|
246
|
+
async function* parseVolundSSE(body) {
|
|
247
|
+
const queue = [];
|
|
248
|
+
const parser = (0, eventsource_parser.createParser)({ onEvent(event) {
|
|
249
|
+
if (!event.data) return;
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = JSON.parse(event.data);
|
|
253
|
+
} catch {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (isVolundEvent(parsed)) queue.push(parsed);
|
|
257
|
+
} });
|
|
258
|
+
const reader = body.getReader();
|
|
259
|
+
const decoder = new TextDecoder();
|
|
260
|
+
try {
|
|
261
|
+
while (true) {
|
|
262
|
+
const { done, value } = await reader.read();
|
|
263
|
+
if (done) break;
|
|
264
|
+
parser.feed(decoder.decode(value, { stream: true }));
|
|
265
|
+
while (queue.length > 0) yield queue.shift();
|
|
266
|
+
}
|
|
267
|
+
const tail = decoder.decode();
|
|
268
|
+
if (tail) {
|
|
269
|
+
parser.feed(tail);
|
|
270
|
+
while (queue.length > 0) yield queue.shift();
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
reader.releaseLock();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/run.ts
|
|
278
|
+
/**
|
|
279
|
+
* `Run` — uma execução de agente em andamento. Espelha o `run` do Cursor SDK:
|
|
280
|
+
* `.stream()` (eventos ao vivo), `.result()` (atalho p/ o texto final) e
|
|
281
|
+
* `.cancel()` (fecha a conexão; o servidor mata a sandbox).
|
|
282
|
+
*/
|
|
283
|
+
var Run = class {
|
|
284
|
+
#id;
|
|
285
|
+
#response;
|
|
286
|
+
#abort;
|
|
287
|
+
#consumed = false;
|
|
288
|
+
constructor(response, id, abort) {
|
|
289
|
+
this.#response = response;
|
|
290
|
+
this.#id = id;
|
|
291
|
+
this.#abort = abort;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* `run_id` (== `thread_id` no Volund OS). Use em `agents.continue`.
|
|
295
|
+
* Para um run NOVO, só fica disponível depois que o primeiro evento
|
|
296
|
+
* (`run_started`) é consumido via `stream()`/`result()` — antes disso é "".
|
|
297
|
+
* Em `agents.continue`, já vem preenchido (você passou o `runId`).
|
|
298
|
+
*/
|
|
299
|
+
get id() {
|
|
300
|
+
return this.#id;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Itera os `VolundEvent` conforme chegam. ⚠️ Consumível UMA vez (é um stream
|
|
304
|
+
* de rede) — não chame `stream()` e `result()` no mesmo `Run`.
|
|
305
|
+
*
|
|
306
|
+
* Se você ABANDONAR o stream no meio (um `break`/`throw` antes de um evento
|
|
307
|
+
* terminal), a conexão é fechada automaticamente — o servidor mata o sandbox e
|
|
308
|
+
* não vaza recurso (§3.6/§4.2 da proposta). Já em `run_finished`/`awaiting_input`
|
|
309
|
+
* o servidor encerra sozinho, então NÃO abortamos (abortar no `awaiting_input`
|
|
310
|
+
* mataria um run parqueado p/ vault e quebraria o resume — §3.5).
|
|
311
|
+
*/
|
|
312
|
+
async *stream() {
|
|
313
|
+
if (this.#consumed) throw new VolundError("Este run já foi consumido (stream/result só uma vez).", { code: "stream_error" });
|
|
314
|
+
this.#consumed = true;
|
|
315
|
+
const body = this.#response.body;
|
|
316
|
+
if (!body) throw new VolundError("Resposta de streaming sem corpo legível.", { code: "stream_error" });
|
|
317
|
+
let serverClosing = false;
|
|
318
|
+
try {
|
|
319
|
+
for await (const event of parseVolundSSE(body)) {
|
|
320
|
+
if (event.type === "run_started" && event.run_id) this.#id = event.run_id;
|
|
321
|
+
if (event.type === "run_finished" || event.type === "awaiting_input") serverClosing = true;
|
|
322
|
+
yield event;
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (this.#abort.signal.aborted) return;
|
|
326
|
+
throw err;
|
|
327
|
+
} finally {
|
|
328
|
+
if (!serverClosing) this.#abort.abort();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Espera o run terminar e devolve o texto final + uso de tokens.
|
|
333
|
+
* Lança `VolundRunFailedError` se o run falhar e `VolundAwaitingInputError`
|
|
334
|
+
* se ele pausar para HITL (vault). Para esses casos, prefira `stream()`.
|
|
335
|
+
*/
|
|
336
|
+
async result() {
|
|
337
|
+
let text = "";
|
|
338
|
+
for await (const event of this.stream()) switch (event.type) {
|
|
339
|
+
case "assistant_text_delta":
|
|
340
|
+
text += event.delta;
|
|
341
|
+
break;
|
|
342
|
+
case "awaiting_input": throw new VolundAwaitingInputError(event.request_id, event.kind);
|
|
343
|
+
case "run_finished":
|
|
344
|
+
if (event.status === "failed") throw new VolundRunFailedError(event.error ?? "Run falhou sem motivo informado.");
|
|
345
|
+
return {
|
|
346
|
+
output: event.output ?? text,
|
|
347
|
+
usage: event.usage
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
output: text,
|
|
352
|
+
usage: null
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Cancela o run: aborta a conexão → o servidor mata a sandbox. */
|
|
356
|
+
cancel() {
|
|
357
|
+
this.#abort.abort();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/agents.ts
|
|
362
|
+
/**
|
|
363
|
+
* `volund.agents` — dispara (`run`) e continua (`continue`) execuções de agente.
|
|
364
|
+
* DX espelha o Cursor SDK: `agents.run(...)` → `Run` com `.stream()/.result()`.
|
|
365
|
+
*/
|
|
366
|
+
/** Combina o sinal do usuário com o sinal interno de `run.cancel()`. */
|
|
367
|
+
function linkSignals(controller, external) {
|
|
368
|
+
if (!external) return;
|
|
369
|
+
if (external.aborted) {
|
|
370
|
+
controller.abort();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
external.addEventListener("abort", () => controller.abort(), { once: true });
|
|
374
|
+
}
|
|
375
|
+
/** V1 só roda na nuvem; o gancho `execution` já existe p/ a V2 (local.cwd). */
|
|
376
|
+
function assertCloud(execution) {
|
|
377
|
+
if (execution && execution !== "cloud") throw new VolundError("execution: \"local\" ainda não é suportado (chega na V2). Use \"cloud\" ou omita.", { code: "unsupported" });
|
|
378
|
+
}
|
|
379
|
+
var Agents = class {
|
|
380
|
+
#http;
|
|
381
|
+
constructor(http) {
|
|
382
|
+
this.#http = http;
|
|
383
|
+
}
|
|
384
|
+
/** Dispara um run novo (cria uma thread) e devolve um `Run` em streaming. */
|
|
385
|
+
async run(options) {
|
|
386
|
+
assertCloud(options.execution);
|
|
387
|
+
const controller = new AbortController();
|
|
388
|
+
linkSignals(controller, options.signal);
|
|
389
|
+
const body = { input: options.input };
|
|
390
|
+
if (options.files?.length) body.files = options.files;
|
|
391
|
+
const res = await postStream(this.#http, `/api/v1/agents/${encodeURIComponent(options.agentId)}/stream`, body, controller.signal);
|
|
392
|
+
return new Run(res, runIdFromResponse(res), controller);
|
|
393
|
+
}
|
|
394
|
+
/** Continua uma conversa existente (mesma thread). */
|
|
395
|
+
async continue(options) {
|
|
396
|
+
assertCloud(options.execution);
|
|
397
|
+
const controller = new AbortController();
|
|
398
|
+
linkSignals(controller, options.signal);
|
|
399
|
+
const body = { input: options.input };
|
|
400
|
+
if (options.files?.length) body.files = options.files;
|
|
401
|
+
return new Run(await postStream(this.#http, `/api/v1/runs/${encodeURIComponent(options.runId)}/stream`, body, controller.signal), options.runId, controller);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* Para um run NOVO o `run_id` só existe no primeiro evento (`run_started`) — o
|
|
406
|
+
* servidor não o devolve em header. Então iniciamos o `Run` com "" e ele faz o
|
|
407
|
+
* backfill do id ao consumir o `run_started` (ver `Run.stream`). Mantemos um
|
|
408
|
+
* fast-path opcional por header caso o servidor passe a ecoá-lo no futuro.
|
|
409
|
+
*/
|
|
410
|
+
function runIdFromResponse(res) {
|
|
411
|
+
return res.headers.get("x-volund-run-id") ?? "";
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/client.ts
|
|
415
|
+
/**
|
|
416
|
+
* `VolundOS` — ponto de entrada do SDK. Configuração mínima: uma API key.
|
|
417
|
+
*
|
|
418
|
+
* const volund = new VolundOS({ apiKey: process.env.VOLUND_API_KEY! });
|
|
419
|
+
* const run = await volund.agents.run({ agentId, input });
|
|
420
|
+
*/
|
|
421
|
+
/** Endpoint de produção do Volund OS (decisão de proposta §6). */
|
|
422
|
+
const DEFAULT_BASE_URL = "https://os.volund.com.br";
|
|
423
|
+
var VolundOS = class {
|
|
424
|
+
/** Disparo e continuação de runs de agente. */
|
|
425
|
+
agents;
|
|
426
|
+
constructor(config) {
|
|
427
|
+
if (!config?.apiKey || typeof config.apiKey !== "string") throw new VolundError("apiKey é obrigatória.", { code: "missing_api_key" });
|
|
428
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
429
|
+
if (typeof fetchImpl !== "function") throw new VolundError("fetch global indisponível. Use Node ≥18 ou injete `fetch` no config.", { code: "unsupported" });
|
|
430
|
+
const http = {
|
|
431
|
+
apiKey: config.apiKey,
|
|
432
|
+
baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
|
|
433
|
+
fetch: (...args) => fetchImpl(...args),
|
|
434
|
+
...config.defaultHeaders ? { defaultHeaders: config.defaultHeaders } : {},
|
|
435
|
+
...config.timeoutMs !== void 0 ? { timeoutMs: config.timeoutMs } : {},
|
|
436
|
+
...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {}
|
|
437
|
+
};
|
|
438
|
+
this.agents = new Agents(http);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/protocol/events.ts
|
|
443
|
+
/**
|
|
444
|
+
* ⚠️ ARQUIVO VENDORADO — NÃO EDITE À MÃO ABAIXO DO SENTINEL.
|
|
445
|
+
*
|
|
446
|
+
* Cópia fiel de `lib/agent/connectors/api/events.ts` do repo `volund-os`
|
|
447
|
+
* (a FONTE ÚNICA do contrato VolundEvent v1). O servidor emite estes eventos;
|
|
448
|
+
* este pacote os entrega tipados. Manter os dois em sincronia é invariante do
|
|
449
|
+
* projeto — o CI roda `scripts/check-protocol-drift.mjs`, que falha se o
|
|
450
|
+
* conteúdo abaixo do sentinel divergir do upstream.
|
|
451
|
+
*
|
|
452
|
+
* Para atualizar: rode `npm run sync:protocol` (copia do volund-os) — nunca
|
|
453
|
+
* edite o corpo manualmente. Promover para um pacote `@volund/protocol`
|
|
454
|
+
* publicado no futuro é trivial: este diretório não importa nada do SDK.
|
|
455
|
+
*/
|
|
456
|
+
/**
|
|
457
|
+
* Contrato público de eventos do Volund OS SDK — VERSÃO 1.
|
|
458
|
+
*
|
|
459
|
+
* Fonte única (graduada de `docs/prototypes/sse-adapter/events.ts`). É o que o
|
|
460
|
+
* servidor (Parte A) emite via SSE e o que o SDK (Parte B) entregará como
|
|
461
|
+
* AsyncIterable<VolundEvent>. Clientes externos dependem disso por anos —
|
|
462
|
+
* NÃO altere os tipos públicos sem bump de SCHEMA_VERSION.
|
|
463
|
+
*
|
|
464
|
+
* DECISÕES DESTA VERSÃO (aprovadas pelo time em 22/06):
|
|
465
|
+
* [D1] Naming = snake_case no wire. Consistente com a API pública que JÁ
|
|
466
|
+
* existe (GET /api/v1/runs/{id} e webhook) E com o padrão do ecossistema:
|
|
467
|
+
* Anthropic é 100% snake_case no fio; Cursor espelha em snake_case os
|
|
468
|
+
* campos estilo Claude Code. camelCase fica reservado p/ ergonomia futura
|
|
469
|
+
* na borda da linguagem (mapeado no SDK), não no contrato.
|
|
470
|
+
* [D2] Status reusa o vocabulário do GET /runs: "completed" | "failed".
|
|
471
|
+
* Pausa (HITL) = UM evento genérico `awaiting_input { kind }`. Na V1 o
|
|
472
|
+
* único `kind` é "vault": runs via API rodam com
|
|
473
|
+
* `--permission-mode bypassPermissions` (lib/agent/v2/run.ts), então NÃO
|
|
474
|
+
* pausam por aprovação de ferramenta. "approval" foi descopado da V1
|
|
475
|
+
* (decisão do time, 22/06) — reintroduzir quando/se a API suportar.
|
|
476
|
+
* [D3] tool_result.is_error CONFIRMADO real no nível stream-json: vem como
|
|
477
|
+
* `is_error: true` dentro do tool_result (no evento cru `user`). O
|
|
478
|
+
* types.ts da Volund não o tipa → o adapter lê do cru via cast. Mantido
|
|
479
|
+
* opcional (só presente/true em erro de ferramenta).
|
|
480
|
+
* [D4] Versionamento (modelo combinado): campo `protocol` no run_started
|
|
481
|
+
* (portátil p/ HTTP e CLI) p/ MAJOR; minor/patch via política
|
|
482
|
+
* "ignore unknown fields" (clientes ignoram campos desconhecidos).
|
|
483
|
+
* [D5] SSE `id:` por evento é emitido pelo ADAPTER (não é campo de payload),
|
|
484
|
+
* RESERVADO p/ reconexão futura (V2). A V1 NÃO promete retomada.
|
|
485
|
+
*
|
|
486
|
+
* ROTEAMENTO DE ERROS (por `type`, nunca por posição):
|
|
487
|
+
* - erro de ferramenta → tool_result.is_error (este arquivo, ToolResultEvent)
|
|
488
|
+
* - falha do run inteiro → run_finished status:"failed" + error
|
|
489
|
+
* - erro de transporte → cru `system/api_retry`: NÃO exposto na V1 (interno;
|
|
490
|
+
* retries são tratados dentro da nuvem).
|
|
491
|
+
*
|
|
492
|
+
* REGRAS DE OURO:
|
|
493
|
+
* 1. NÃO vazar interno: nada de session_id, sandbox, scratchDir, nome do
|
|
494
|
+
* executor (claude-code/cursor), api_retry, nem formatos do AI SDK.
|
|
495
|
+
* 2. Versionar (SCHEMA_VERSION). Mudança incompatível => bump MAJOR.
|
|
496
|
+
* 3. input/output são `unknown` mas DEVEM ser JSON-serializáveis.
|
|
497
|
+
* 4. Fonte única: servidor importa daqui; o pacote @volund/sdk publica daqui.
|
|
498
|
+
*
|
|
499
|
+
* INVARIANTES DO ADAPTER (blindagem contra inconsistências):
|
|
500
|
+
* I1. tool_call.input é emitido COMPLETO. O input chega vazio no 1º snapshot e
|
|
501
|
+
* completo depois. O adapter acumula input_json_delta e só emite no
|
|
502
|
+
* content_block_stop — nunca um input meio-vazio.
|
|
503
|
+
* I2. tool_result.output é NORMALIZADO: bloco MCP [{type:"text",text}] vira
|
|
504
|
+
* string; imagem `data:image/...` vira placeholder (não trafega binário);
|
|
505
|
+
* tamanho limitado. Saída sempre JSON-serializável e enxuta.
|
|
506
|
+
* I3. Sentinel de vault (__vault_request_pending__:<id>) NUNCA vaza como
|
|
507
|
+
* tool_result — o adapter detecta e emite awaiting_input{kind:"vault"},
|
|
508
|
+
* suprimindo o sentinel e o run_finished subsequente.
|
|
509
|
+
* I4. Texto duplicado: o adapter faz diff entre partials e snapshot cumulativo
|
|
510
|
+
* (gate hasSeenPartials), nunca reemite texto já enviado.
|
|
511
|
+
* I5. Stream drenado por inteiro (dispara persister + hooks via o tap em
|
|
512
|
+
* runAgentV2); abort do cliente → kill(). RESSALVA HITL: no vault o run
|
|
513
|
+
* termina sozinho (emite `result`); o adapter NÃO dá kill — só suprime a
|
|
514
|
+
* saída ao cliente e continua drenando até o `result` natural, pra o
|
|
515
|
+
* persister flipar a thread pra `awaiting_vault` e `handle.finished`
|
|
516
|
+
* resolver. Ver sse-adapter.ts.
|
|
517
|
+
* I6. V1 achata turnos: um único stream ordenado de deltas, sem id de bloco/
|
|
518
|
+
* turno no payload.
|
|
519
|
+
*/
|
|
520
|
+
/** Versão do schema. Bump em qualquer mudança incompatível. */
|
|
521
|
+
const SCHEMA_VERSION = "v1";
|
|
522
|
+
//#endregion
|
|
523
|
+
exports.Agents = Agents;
|
|
524
|
+
exports.Run = Run;
|
|
525
|
+
exports.SCHEMA_VERSION = SCHEMA_VERSION;
|
|
526
|
+
exports.VolundAuthError = VolundAuthError;
|
|
527
|
+
exports.VolundAwaitingInputError = VolundAwaitingInputError;
|
|
528
|
+
exports.VolundError = VolundError;
|
|
529
|
+
exports.VolundForbiddenError = VolundForbiddenError;
|
|
530
|
+
exports.VolundNotFoundError = VolundNotFoundError;
|
|
531
|
+
exports.VolundOS = VolundOS;
|
|
532
|
+
exports.VolundRunBusyError = VolundRunBusyError;
|
|
533
|
+
exports.VolundRunFailedError = VolundRunFailedError;
|
|
534
|
+
exports.errorFromApiResponse = errorFromApiResponse;
|
|
535
|
+
exports.parseVolundSSE = parseVolundSSE;
|
|
536
|
+
|
|
537
|
+
//# sourceMappingURL=index.cjs.map
|