@johpaz/hive-sdk 0.0.17 → 0.0.18
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 +83 -203
- package/bun.lock +543 -0
- package/bunfig.toml +7 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +60 -0
- package/package.json +1 -1
- package/packages/core/src/agent/selectors/ToolSelector.ts +1 -0
- package/packages/core/src/api/createAgent.ts +10 -0
- package/packages/core/src/config/loader.ts +2 -2
- package/packages/core/src/index.ts +13 -0
- package/packages/core/src/skills/bundled-data.generated.ts +50 -0
- package/packages/core/src/skills/skills.test.ts +21 -0
- package/packages/core/src/tools/index.ts +1 -0
- package/packages/core/src/tools/web/api-request.test.ts +170 -0
- package/packages/core/src/tools/web/api-request.ts +239 -0
- package/packages/core/src/tools/web/browser-click.ts +2 -2
- package/packages/core/src/tools/web/browser-extract.ts +22 -6
- package/packages/core/src/tools/web/browser-navigate.ts +34 -18
- package/packages/core/src/tools/web/browser-screenshot.ts +40 -8
- package/packages/core/src/tools/web/browser-script.ts +2 -2
- package/packages/core/src/tools/web/browser-service.test.ts +83 -0
- package/packages/core/src/tools/web/browser-service.ts +290 -341
- package/packages/core/src/tools/web/browser-type.ts +2 -2
- package/packages/core/src/tools/web/browser-wait.ts +2 -2
- package/packages/core/src/tools/web/index.ts +3 -0
- package/CHANGELOG.md +0 -72
- package/docs/README.md +0 -161
|
@@ -80,6 +80,57 @@ const tools = selectTools("Buscar archivos en el proyecto");
|
|
|
80
80
|
const webTools = tools.filter(t => t.category === "web");
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
### Built-in Web + API Tools
|
|
84
|
+
|
|
85
|
+
El SDK expone herramientas web/browser listas para usar:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import {
|
|
89
|
+
webSearchTool,
|
|
90
|
+
webFetchTool,
|
|
91
|
+
apiRequestTool,
|
|
92
|
+
browserNavigateTool,
|
|
93
|
+
browserScreenshotTool,
|
|
94
|
+
browserClickTool,
|
|
95
|
+
browserTypeTool,
|
|
96
|
+
browserExtractTool,
|
|
97
|
+
browserScriptTool,
|
|
98
|
+
browserWaitTool,
|
|
99
|
+
} from "@johpaz/hive-sdk";
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
#### Browser automation (agent-browser)
|
|
103
|
+
|
|
104
|
+
Las herramientas `browser_*` usan [`agent-browser`](https://www.npmjs.com/package/agent-browser), un CLI Rust que gestiona Chrome/Chromium internamente vía CDP. En el primer uso se instala automáticamente en `~/.hive/agent-browser` y se descarga Chrome si es necesario.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { initializeBrowserService, getBrowserService } from "@johpaz/hive-sdk";
|
|
108
|
+
|
|
109
|
+
const browserService = initializeBrowserService(config);
|
|
110
|
+
await browserService.start();
|
|
111
|
+
|
|
112
|
+
const view = await browserService.getView();
|
|
113
|
+
await view.navigate("https://example.com");
|
|
114
|
+
const snapshot = await view.snapshot({ compact: true, depth: 3 });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### api_request
|
|
118
|
+
|
|
119
|
+
Conecta APIs REST con autenticación y métodos HTTP:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const result = await apiRequestTool.execute({
|
|
123
|
+
url: "https://api.example.com/items",
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: { "X-Custom": "value" },
|
|
126
|
+
body: { name: "example" },
|
|
127
|
+
auth: { type: "bearer", token: process.env.API_TOKEN! },
|
|
128
|
+
timeoutMs: 30000,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Auth soportada: bearer, basic, api_key (header o query)
|
|
132
|
+
```
|
|
133
|
+
|
|
83
134
|
---
|
|
84
135
|
|
|
85
136
|
## Skills
|
|
@@ -115,6 +166,15 @@ const skills = loader.list();
|
|
|
115
166
|
const skill = loader.get("file-manager");
|
|
116
167
|
```
|
|
117
168
|
|
|
169
|
+
### Skills empaquetadas
|
|
170
|
+
|
|
171
|
+
El SDK incluye skills empaquetadas para casos comunes. Algunas útiles para web y APIs:
|
|
172
|
+
|
|
173
|
+
- `web_research` — búsqueda y síntesis con `web_search` + `web_fetch`.
|
|
174
|
+
- `web_browser_research` — investigación profunda combinando `web_search` con navegación real (`browser_navigate`, `browser_extract`) para sitios dinámicos.
|
|
175
|
+
- `browser_scrape` — captura de contenido renderizado con screenshots.
|
|
176
|
+
- `browser_automate` — automatización de flujos web (clicks, formularios).
|
|
177
|
+
|
|
118
178
|
---
|
|
119
179
|
|
|
120
180
|
## MCP
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johpaz/hive-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -147,6 +147,7 @@ export const CORE_TOOL_CATALOG: ToolDescriptor[] = [
|
|
|
147
147
|
// Web tools
|
|
148
148
|
{ name: "web_search", description: "Search web for current information, find up-to-date news facts and research. Spanish keywords: buscar en internet, buscar web, información, noticias, investigación, buscar", category: "web", abstractionLevel: "atomic" },
|
|
149
149
|
{ name: "web_fetch", description: "Fetch content from URL, download and extract content from web pages. Spanish keywords: obtener página, descargar web, extraer contenido, obtener contenido, página web", category: "web", abstractionLevel: "atomic" },
|
|
150
|
+
{ name: "api_request", description: "Connect to REST APIs, make HTTP requests with authentication and custom headers. Spanish keywords: conectar api, peticion http, llamada api, rest api, endpoint, bearer token, api key, basic auth", category: "web", abstractionLevel: "atomic" },
|
|
150
151
|
|
|
151
152
|
// Memory tools
|
|
152
153
|
{ name: "memory_write", description: "Store in long-term memory, save information to persistent memory for later retrieval. Spanish keywords: guardar memoria, guardar información, recordar, guardar dato, memoria", category: "memory", abstractionLevel: "atomic" },
|
|
@@ -37,6 +37,16 @@ export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
|
37
37
|
await initializeDatabase();
|
|
38
38
|
|
|
39
39
|
const coreConfig = await loadConfig();
|
|
40
|
+
|
|
41
|
+
// Initialize browser automation (agent-browser) if enabled
|
|
42
|
+
try {
|
|
43
|
+
const { initializeBrowserService } = await import("../tools/web/browser-service.ts");
|
|
44
|
+
const browserService = initializeBrowserService(coreConfig);
|
|
45
|
+
await browserService.start();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.warn(`Browser service initialization skipped: ${(err as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
const allBuiltInTools = createAllTools(coreConfig);
|
|
41
51
|
|
|
42
52
|
const customTools = (config.tools ?? []).map(t => ({
|
|
@@ -116,7 +116,7 @@ const WebConfigSchema = z.object({
|
|
|
116
116
|
|
|
117
117
|
const BrowserConfigSchema = z.object({
|
|
118
118
|
enabled: z.boolean().optional(),
|
|
119
|
-
|
|
119
|
+
sessionName: z.string().optional(),
|
|
120
120
|
headless: z.boolean().optional(),
|
|
121
121
|
timeoutMs: z.number().optional(),
|
|
122
122
|
});
|
|
@@ -437,7 +437,7 @@ function buildDefaultConfig(): Config {
|
|
|
437
437
|
},
|
|
438
438
|
browser: {
|
|
439
439
|
enabled: true,
|
|
440
|
-
|
|
440
|
+
sessionName: "hive",
|
|
441
441
|
headless: true,
|
|
442
442
|
timeoutMs: 30000,
|
|
443
443
|
},
|
|
@@ -8,6 +8,19 @@ export type { ToolDefinition } from "./tools/ToolRegistry.ts";
|
|
|
8
8
|
export { ToolRegistry } from "./tools/ToolRegistry.ts";
|
|
9
9
|
export { ToolExecutor } from "./tools/ToolExecutor.ts";
|
|
10
10
|
export type { ToolExecutionResult } from "./tools/ToolExecutor.ts";
|
|
11
|
+
export {
|
|
12
|
+
webSearchTool,
|
|
13
|
+
webFetchTool,
|
|
14
|
+
apiRequestTool,
|
|
15
|
+
browserNavigateTool,
|
|
16
|
+
browserScreenshotTool,
|
|
17
|
+
browserClickTool,
|
|
18
|
+
browserTypeTool,
|
|
19
|
+
browserExtractTool,
|
|
20
|
+
browserScriptTool,
|
|
21
|
+
browserWaitTool,
|
|
22
|
+
} from "./tools/index.ts";
|
|
23
|
+
export type { ApiAuth, HttpMethod, ResponseFormat } from "./tools/web/api-request.ts";
|
|
11
24
|
|
|
12
25
|
// ─── Skills ──────────────────────────────────────────────────────────────────
|
|
13
26
|
export { defineSkill } from "./skills/defineSkill.ts";
|
|
@@ -338,6 +338,56 @@ Esta skill se activa para automatizar flujos de interacción con aplicaciones we
|
|
|
338
338
|
- ❌ No esperar carga de página
|
|
339
339
|
- ❌ Ignorar errores de elementos
|
|
340
340
|
- ❌ No verificar estado después de acciones
|
|
341
|
+
`,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "web_browser_research",
|
|
345
|
+
description: `Search the web and navigate results with a real browser to extract content from dynamic or JavaScript-heavy sites`,
|
|
346
|
+
category: "web",
|
|
347
|
+
version: "1.0.0",
|
|
348
|
+
tools: ["web_search","browser_navigate","browser_extract","web_fetch"],
|
|
349
|
+
triggers: ["investigá en web con navegador","web browser research","buscá y navegá","search and browse","research with browser","navegá los resultados","browse search results","contenido dinámico","dynamic content research","sitios con javascript","javascript sites research"],
|
|
350
|
+
body: `
|
|
351
|
+
# Web Browser Research Skill
|
|
352
|
+
|
|
353
|
+
## Cuándo se Activa
|
|
354
|
+
|
|
355
|
+
Esta skill se activa cuando el usuario necesita investigación web profunda, especialmente cuando:
|
|
356
|
+
- Los resultados de búsqueda pueden requerir navegación real por sitios dinámicos.
|
|
357
|
+
- El contenido objetivo está renderizado con JavaScript (SPAs, dashboards, etc.).
|
|
358
|
+
- Se necesita extraer datos estructurados de páginas web.
|
|
359
|
+
|
|
360
|
+
## Herramientas Disponibles
|
|
361
|
+
|
|
362
|
+
| Tool | Qué hace | Cuándo usarla |
|
|
363
|
+
|------|----------|---------------|
|
|
364
|
+
| \`web_search\` | Busca en internet y devuelve resultados | Encontrar URLs relevantes |
|
|
365
|
+
| \`browser_navigate\` | Navega y renderiza la página completa | Sitios dinámicos con JavaScript |
|
|
366
|
+
| \`browser_extract\` | Extrae datos con selectores CSS/XPath | Obtener contenido estructurado |
|
|
367
|
+
| \`web_fetch\` | Descarga contenido estático | Páginas simples o respaldo |
|
|
368
|
+
|
|
369
|
+
## Workflow
|
|
370
|
+
|
|
371
|
+
1. **Buscar** → \`web_search({ query, numResults: 5 })\`
|
|
372
|
+
2. **Seleccionar fuentes** → Elegir 2-3 URLs relevantes y confiables.
|
|
373
|
+
3. **Navegar** → \`browser_navigate({ url })\` para cada fuente dinámica.
|
|
374
|
+
4. **Extraer** → \`browser_extract({ selector: "article, .content, main" })\` o similar.
|
|
375
|
+
5. **Respaldo estático** → Si el browser falla, usar \`web_fetch({ url })\`.
|
|
376
|
+
6. **Sintetizar** → Responder con puntos clave y citas con URLs completas.
|
|
377
|
+
|
|
378
|
+
## Mejores Prácticas
|
|
379
|
+
|
|
380
|
+
- Priorizar sitios oficiales, documentación y fuentes primarias.
|
|
381
|
+
- Usar selectores estables (etiquetas semánticas como \`article\`, \`main\`).
|
|
382
|
+
- Si el contenido es largo, extraer por secciones.
|
|
383
|
+
- Siempre incluir URLs de fuentes en la respuesta final.
|
|
384
|
+
|
|
385
|
+
## Errores a Evitar
|
|
386
|
+
|
|
387
|
+
- ❌ Usar solo \`web_fetch\` para SPAs sin probar el browser primero.
|
|
388
|
+
- ❌ Seleccionar selectores frágiles basados en clases aleatorias.
|
|
389
|
+
- ❌ No citar las fuentes usadas.
|
|
390
|
+
- ❌ Confundir snippets de búsqueda con contenido completo verificado.
|
|
341
391
|
`,
|
|
342
392
|
},
|
|
343
393
|
{
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { BUNDLED_SKILLS_DATA } from "./bundled-data.generated.ts";
|
|
3
|
+
|
|
4
|
+
describe("Bundled skills", () => {
|
|
5
|
+
it("includes web_browser_research skill", () => {
|
|
6
|
+
const skill = BUNDLED_SKILLS_DATA.find((s) => s.name === "web_browser_research");
|
|
7
|
+
expect(skill).toBeDefined();
|
|
8
|
+
expect(skill?.category).toBe("web");
|
|
9
|
+
expect(skill?.tools).toContain("web_search");
|
|
10
|
+
expect(skill?.tools).toContain("browser_navigate");
|
|
11
|
+
expect(skill?.tools).toContain("browser_extract");
|
|
12
|
+
expect(skill?.triggers.length).toBeGreaterThan(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("includes existing web and browser skills", () => {
|
|
16
|
+
const names = BUNDLED_SKILLS_DATA.map((s) => s.name);
|
|
17
|
+
expect(names).toContain("web_research");
|
|
18
|
+
expect(names).toContain("browser_scrape");
|
|
19
|
+
expect(names).toContain("browser_automate");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { apiRequestTool } from "./api-request.ts";
|
|
3
|
+
|
|
4
|
+
describe("apiRequestTool", () => {
|
|
5
|
+
let originalFetch: typeof fetch;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
originalFetch = globalThis.fetch;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
globalThis.fetch = originalFetch;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function mockFetch(response: Response) {
|
|
16
|
+
globalThis.fetch = Object.assign(async () => response, { preconnect: async () => undefined }) as typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mockResponse(
|
|
20
|
+
body: BodyInit,
|
|
21
|
+
init: ResponseInit & { headers?: Record<string, string> } = {}
|
|
22
|
+
): Response {
|
|
23
|
+
return new Response(body, {
|
|
24
|
+
status: init.status ?? 200,
|
|
25
|
+
statusText: init.statusText ?? "OK",
|
|
26
|
+
headers: init.headers ?? {},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("executes a simple GET request", async () => {
|
|
31
|
+
let capturedUrl = "";
|
|
32
|
+
let capturedInit: RequestInit = {};
|
|
33
|
+
globalThis.fetch = Object.assign(async (url, init) => {
|
|
34
|
+
capturedUrl = url.toString();
|
|
35
|
+
capturedInit = init ?? {};
|
|
36
|
+
return mockResponse(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json" } });
|
|
37
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
38
|
+
|
|
39
|
+
const result = await apiRequestTool.execute({ url: "https://api.example.com/data" });
|
|
40
|
+
|
|
41
|
+
expect(capturedUrl).toBe("https://api.example.com/data");
|
|
42
|
+
expect(capturedInit.method).toBe("GET");
|
|
43
|
+
expect(result).toMatchObject({
|
|
44
|
+
ok: true,
|
|
45
|
+
status: 200,
|
|
46
|
+
data: { ok: true },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("sends POST with JSON body", async () => {
|
|
51
|
+
let capturedInit: RequestInit = {};
|
|
52
|
+
globalThis.fetch = Object.assign(async (_url, init) => {
|
|
53
|
+
capturedInit = init ?? {};
|
|
54
|
+
return mockResponse(JSON.stringify({ id: 1 }), { headers: { "content-type": "application/json" } });
|
|
55
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
56
|
+
|
|
57
|
+
const result = await apiRequestTool.execute({
|
|
58
|
+
url: "https://api.example.com/items",
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: { name: "test" },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(capturedInit.method).toBe("POST");
|
|
64
|
+
expect(capturedInit.headers).toMatchObject({ "content-type": "application/json" });
|
|
65
|
+
expect(capturedInit.body).toBe(JSON.stringify({ name: "test" }));
|
|
66
|
+
expect(result).toMatchObject({ ok: true, data: { id: 1 } });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("applies bearer auth", async () => {
|
|
70
|
+
let capturedInit: RequestInit = {};
|
|
71
|
+
globalThis.fetch = Object.assign(async (_url, init) => {
|
|
72
|
+
capturedInit = init ?? {};
|
|
73
|
+
return mockResponse("{}");
|
|
74
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
75
|
+
|
|
76
|
+
await apiRequestTool.execute({
|
|
77
|
+
url: "https://api.example.com/private",
|
|
78
|
+
auth: { type: "bearer", token: "secret-token" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(new Headers(capturedInit.headers).get("Authorization")).toBe("Bearer secret-token");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("applies basic auth", async () => {
|
|
85
|
+
let capturedInit: RequestInit = {};
|
|
86
|
+
globalThis.fetch = Object.assign(async (_url, init) => {
|
|
87
|
+
capturedInit = init ?? {};
|
|
88
|
+
return mockResponse("{}");
|
|
89
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
90
|
+
|
|
91
|
+
await apiRequestTool.execute({
|
|
92
|
+
url: "https://api.example.com/private",
|
|
93
|
+
auth: { type: "basic", username: "alice", password: "secret" },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(new Headers(capturedInit.headers).get("Authorization")).toBe(`Basic ${btoa("alice:secret")}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("applies api_key in header", async () => {
|
|
100
|
+
let capturedInit: RequestInit = {};
|
|
101
|
+
globalThis.fetch = Object.assign(async (_url, init) => {
|
|
102
|
+
capturedInit = init ?? {};
|
|
103
|
+
return mockResponse("{}");
|
|
104
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
105
|
+
|
|
106
|
+
await apiRequestTool.execute({
|
|
107
|
+
url: "https://api.example.com/private",
|
|
108
|
+
auth: { type: "api_key", in: "header", name: "X-API-Key", value: "abc123" },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(new Headers(capturedInit.headers).get("X-API-Key")).toBe("abc123");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("applies api_key in query", async () => {
|
|
115
|
+
let capturedUrl = "";
|
|
116
|
+
globalThis.fetch = Object.assign(async (url, _init) => {
|
|
117
|
+
capturedUrl = url.toString();
|
|
118
|
+
return mockResponse("{}");
|
|
119
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
120
|
+
|
|
121
|
+
await apiRequestTool.execute({
|
|
122
|
+
url: "https://api.example.com/private",
|
|
123
|
+
auth: { type: "api_key", in: "query", name: "api_key", value: "abc123" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(capturedUrl).toBe("https://api.example.com/private?api_key=abc123");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns error for non-ok HTTP response", async () => {
|
|
130
|
+
globalThis.fetch = Object.assign(async () => mockResponse("Not found", { status: 404, statusText: "Not Found" }), { preconnect: async () => undefined }) as typeof fetch;
|
|
131
|
+
|
|
132
|
+
const result = await apiRequestTool.execute({ url: "https://api.example.com/missing" });
|
|
133
|
+
|
|
134
|
+
expect(result).toMatchObject({
|
|
135
|
+
ok: false,
|
|
136
|
+
status: 404,
|
|
137
|
+
error: "HTTP 404: Not Found",
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects non-http URLs", async () => {
|
|
142
|
+
const result = await apiRequestTool.execute({ url: "file:///etc/passwd" });
|
|
143
|
+
|
|
144
|
+
expect(result).toMatchObject({
|
|
145
|
+
ok: false,
|
|
146
|
+
error: "Invalid URL. Only http:// and https:// are allowed.",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("respects timeout", async () => {
|
|
151
|
+
globalThis.fetch = Object.assign(async (_url, init) => {
|
|
152
|
+
expect(init?.signal).toBeDefined();
|
|
153
|
+
return mockResponse("{}");
|
|
154
|
+
}, { preconnect: async () => undefined }) as typeof fetch;
|
|
155
|
+
|
|
156
|
+
const result = await apiRequestTool.execute({ url: "https://api.example.com/data", timeoutMs: 5000 });
|
|
157
|
+
expect(result).toMatchObject({ ok: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns text for non-json responses", async () => {
|
|
161
|
+
globalThis.fetch = Object.assign(async () => mockResponse("hello world", { headers: { "content-type": "text/plain" } }), { preconnect: async () => undefined }) as typeof fetch;
|
|
162
|
+
|
|
163
|
+
const result = await apiRequestTool.execute({ url: "https://api.example.com/text" });
|
|
164
|
+
|
|
165
|
+
expect(result).toMatchObject({
|
|
166
|
+
ok: true,
|
|
167
|
+
data: "hello world",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api_request - Make generic HTTP requests to connect REST APIs
|
|
3
|
+
*
|
|
4
|
+
* @category web
|
|
5
|
+
* @seedId api_request
|
|
6
|
+
* @spanish conectar api, peticion http, llamada api, rest api, endpoint
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Tool } from "../types.ts";
|
|
10
|
+
import { logger } from "../../utils/logger.ts";
|
|
11
|
+
|
|
12
|
+
const log = logger.child("api-request");
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
+
const MAX_RESPONSE_CHARS = 100_000;
|
|
16
|
+
|
|
17
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
|
|
18
|
+
export type ResponseFormat = "auto" | "json" | "text" | "binary";
|
|
19
|
+
|
|
20
|
+
export interface ApiAuthBearer {
|
|
21
|
+
type: "bearer";
|
|
22
|
+
token: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ApiAuthBasic {
|
|
26
|
+
type: "basic";
|
|
27
|
+
username: string;
|
|
28
|
+
password: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiAuthApiKey {
|
|
32
|
+
type: "api_key";
|
|
33
|
+
in: "header" | "query";
|
|
34
|
+
name: string;
|
|
35
|
+
value: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ApiAuth = ApiAuthBearer | ApiAuthBasic | ApiAuthApiKey;
|
|
39
|
+
|
|
40
|
+
function applyAuth(
|
|
41
|
+
url: string,
|
|
42
|
+
init: RequestInit,
|
|
43
|
+
auth?: ApiAuth
|
|
44
|
+
): { url: string; init: RequestInit } {
|
|
45
|
+
if (!auth) return { url, init };
|
|
46
|
+
|
|
47
|
+
const headers = new Headers(init.headers);
|
|
48
|
+
|
|
49
|
+
switch (auth.type) {
|
|
50
|
+
case "bearer":
|
|
51
|
+
headers.set("Authorization", `Bearer ${auth.token}`);
|
|
52
|
+
break;
|
|
53
|
+
case "basic": {
|
|
54
|
+
const credentials = btoa(`${auth.username}:${auth.password}`);
|
|
55
|
+
headers.set("Authorization", `Basic ${credentials}`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case "api_key":
|
|
59
|
+
if (auth.in === "query") {
|
|
60
|
+
const parsed = new URL(url);
|
|
61
|
+
parsed.searchParams.set(auth.name, auth.value);
|
|
62
|
+
url = parsed.toString();
|
|
63
|
+
} else {
|
|
64
|
+
headers.set(auth.name, auth.value);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { url, init: { ...init, headers } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isValidHttpUrl(url: string): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(url);
|
|
75
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function parseResponse(
|
|
82
|
+
response: Response,
|
|
83
|
+
format: ResponseFormat
|
|
84
|
+
): Promise<{ data: unknown; contentType: string }> {
|
|
85
|
+
const contentType = response.headers.get("content-type") || "";
|
|
86
|
+
|
|
87
|
+
if (format === "json" || (format === "auto" && contentType.includes("application/json"))) {
|
|
88
|
+
const json = await response.json();
|
|
89
|
+
return { data: json, contentType };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (format === "text" || (format === "auto" && contentType.includes("text/"))) {
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (format === "binary") {
|
|
98
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
99
|
+
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
|
100
|
+
return { data: base64, contentType };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback: try text, then JSON
|
|
104
|
+
const text = await response.text();
|
|
105
|
+
if (text.trim().startsWith("{") || text.trim().startsWith("[")) {
|
|
106
|
+
try {
|
|
107
|
+
return { data: JSON.parse(text), contentType };
|
|
108
|
+
} catch {
|
|
109
|
+
/* fallthrough */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { data: text.slice(0, MAX_RESPONSE_CHARS), contentType };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const apiRequestTool: Tool = {
|
|
116
|
+
name: "api_request",
|
|
117
|
+
description:
|
|
118
|
+
"Connect to REST APIs: make HTTP requests with methods, headers, body and authentication. Spanish: conectar api, peticion http, llamada api, rest api, endpoint, bearer, api key",
|
|
119
|
+
parameters: {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
url: {
|
|
123
|
+
type: "string",
|
|
124
|
+
description: "The API endpoint URL (http:// or https://)",
|
|
125
|
+
},
|
|
126
|
+
method: {
|
|
127
|
+
type: "string",
|
|
128
|
+
enum: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
|
129
|
+
description: "HTTP method (default: GET)",
|
|
130
|
+
},
|
|
131
|
+
headers: {
|
|
132
|
+
type: "object",
|
|
133
|
+
additionalProperties: { type: "string" },
|
|
134
|
+
description: "Optional HTTP headers as key-value pairs",
|
|
135
|
+
},
|
|
136
|
+
body: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "Request body. Objects should be passed as JSON strings; strings are sent as-is",
|
|
139
|
+
},
|
|
140
|
+
auth: {
|
|
141
|
+
type: "object",
|
|
142
|
+
description: "Optional authentication configuration",
|
|
143
|
+
properties: {
|
|
144
|
+
type: {
|
|
145
|
+
type: "string",
|
|
146
|
+
enum: ["bearer", "basic", "api_key"],
|
|
147
|
+
},
|
|
148
|
+
token: { type: "string" },
|
|
149
|
+
username: { type: "string" },
|
|
150
|
+
password: { type: "string" },
|
|
151
|
+
in: { type: "string", enum: ["header", "query"] },
|
|
152
|
+
name: { type: "string" },
|
|
153
|
+
value: { type: "string" },
|
|
154
|
+
},
|
|
155
|
+
required: ["type"],
|
|
156
|
+
},
|
|
157
|
+
timeoutMs: {
|
|
158
|
+
type: "number",
|
|
159
|
+
description: "Request timeout in milliseconds (default: 30000)",
|
|
160
|
+
},
|
|
161
|
+
responseFormat: {
|
|
162
|
+
type: "string",
|
|
163
|
+
enum: ["auto", "json", "text", "binary"],
|
|
164
|
+
description: "How to parse the response (default: auto)",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
required: ["url"],
|
|
168
|
+
},
|
|
169
|
+
execute: async (params: Record<string, unknown>) => {
|
|
170
|
+
const url = params.url as string;
|
|
171
|
+
const method = (params.method as HttpMethod) ?? "GET";
|
|
172
|
+
const headers = (params.headers as Record<string, string>) ?? {};
|
|
173
|
+
const bodyParam = params.body;
|
|
174
|
+
const auth = params.auth as ApiAuth | undefined;
|
|
175
|
+
const timeoutMs = (params.timeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
176
|
+
const responseFormat = (params.responseFormat as ResponseFormat) ?? "auto";
|
|
177
|
+
|
|
178
|
+
if (!isValidHttpUrl(url)) {
|
|
179
|
+
return { ok: false, error: "Invalid URL. Only http:// and https:// are allowed." };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log.info(`API request: ${method} ${url}`);
|
|
183
|
+
|
|
184
|
+
let body: BodyInit | undefined;
|
|
185
|
+
const finalHeaders: Record<string, string> = { ...headers };
|
|
186
|
+
|
|
187
|
+
if (bodyParam !== undefined) {
|
|
188
|
+
if (typeof bodyParam === "string") {
|
|
189
|
+
body = bodyParam;
|
|
190
|
+
} else {
|
|
191
|
+
body = JSON.stringify(bodyParam);
|
|
192
|
+
if (!finalHeaders["content-type"] && !finalHeaders["Content-Type"]) {
|
|
193
|
+
finalHeaders["content-type"] = "application/json";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let init: RequestInit = {
|
|
199
|
+
method,
|
|
200
|
+
headers: finalHeaders,
|
|
201
|
+
body,
|
|
202
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const final = applyAuth(url, init, auth);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch(final.url, final.init);
|
|
209
|
+
const { data, contentType } = await parseResponse(response, responseFormat);
|
|
210
|
+
|
|
211
|
+
const responseHeaders: Record<string, string> = {};
|
|
212
|
+
response.headers.forEach((value, key) => {
|
|
213
|
+
responseHeaders[key] = value;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const result = {
|
|
217
|
+
ok: response.ok,
|
|
218
|
+
status: response.status,
|
|
219
|
+
statusText: response.statusText,
|
|
220
|
+
url: final.url,
|
|
221
|
+
contentType,
|
|
222
|
+
headers: responseHeaders,
|
|
223
|
+
data,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
log.warn(`API request returned ${response.status} for ${final.url}`);
|
|
228
|
+
return { ok: false, error: `HTTP ${response.status}: ${response.statusText}`, ...result };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
log.info(`API request successful: ${response.status} ${final.url}`);
|
|
232
|
+
return result;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const message = (error as Error).message;
|
|
235
|
+
log.error(`API request failed: ${message}`);
|
|
236
|
+
return { ok: false, error: `API request failed: ${message}` };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
};
|
|
@@ -43,7 +43,7 @@ export const browserClickTool: Tool = {
|
|
|
43
43
|
log.warn("Browser not available");
|
|
44
44
|
return {
|
|
45
45
|
ok: false,
|
|
46
|
-
error: "Browser automation not available. Install
|
|
46
|
+
error: "Browser automation not available. Install agent-browser.",
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -51,7 +51,7 @@ export const browserClickTool: Tool = {
|
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
53
|
const view = await browserService.getView();
|
|
54
|
-
if (!view) return { ok: false, error: "Browser automation not available. Install
|
|
54
|
+
if (!view) return { ok: false, error: "Browser automation not available. Install agent-browser." };
|
|
55
55
|
|
|
56
56
|
if (url) {
|
|
57
57
|
await view.navigate(url);
|