@johpaz/hive-sdk 0.0.17 → 0.1.3
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 +833 -0
- package/bunfig.toml +7 -0
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +60 -0
- package/docs/HIVE-HARNESS.md +113 -0
- package/package.json +36 -2
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +13 -2
- package/packages/core/src/ace/Tracer.ts +1 -1
- package/packages/core/src/agent/AgentRunner.ts +12 -0
- package/packages/core/src/agent/ContextCompiler.ts +4 -4
- package/packages/core/src/agent/ConversationStore.ts +30 -20
- package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
- package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
- package/packages/core/src/agent/selectors/ToolSelector.ts +54 -89
- package/packages/core/src/api/createAgent.ts +10 -0
- package/packages/core/src/auth/auth.ts +36 -23
- package/packages/core/src/config/loader.ts +2 -2
- package/packages/core/src/harness/boot-id.ts +20 -0
- package/packages/core/src/harness/collections.ts +98 -0
- package/packages/core/src/harness/db-helpers.ts +87 -0
- package/packages/core/src/harness/durable-queue.ts +337 -0
- package/packages/core/src/harness/goal-verifier.ts +141 -0
- package/packages/core/src/harness/harness.test.ts +236 -0
- package/packages/core/src/harness/index.ts +34 -0
- package/packages/core/src/harness/job-store.ts +399 -0
- package/packages/core/src/harness/proof-packet.ts +69 -0
- package/packages/core/src/harness/reconcile.ts +149 -0
- package/packages/core/src/harness/run-epoch.ts +32 -0
- package/packages/core/src/harness/run-store.ts +334 -0
- package/packages/core/src/index.ts +19 -0
- package/packages/core/src/memory/Scratchpad.test.ts +23 -21
- package/packages/core/src/memory/Scratchpad.ts +41 -24
- 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/storage/HiveDBStorage.ts +64 -0
- package/packages/core/src/storage/SQLiteStorage.ts +7 -0
- package/packages/core/src/storage/hiveSeed.ts +308 -0
- package/packages/core/src/storage/hiveStorage.test.ts +38 -0
- package/packages/core/src/storage/index.ts +10 -0
- package/packages/core/src/storage/seed.ts +5 -1
- package/packages/core/src/storage/usage.ts +106 -167
- package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
- package/packages/core/src/tools/agents/get-available-models.ts +52 -56
- package/packages/core/src/tools/agents/index.ts +77 -60
- package/packages/core/src/tools/core/index.ts +106 -291
- package/packages/core/src/tools/index.ts +1 -0
- package/packages/core/src/tools/meeting/index.ts +83 -93
- 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/packages/core/src/utils/toon.ts +4 -4
- package/CHANGELOG.md +0 -72
- package/docs/README.md +0 -161
|
@@ -2,20 +2,33 @@
|
|
|
2
2
|
* Meeting Tools - 4 tools para transcripción de reuniones
|
|
3
3
|
*
|
|
4
4
|
* @category meeting
|
|
5
|
-
*
|
|
6
|
-
* - meeting_start — Inicia una sesión de transcripción
|
|
7
|
-
* - meeting_add_segment — Transcribe un chunk de audio y lo agrega a la sesión
|
|
8
|
-
* - meeting_stop — Detiene la sesión de transcripción
|
|
9
|
-
* - meeting_report — Obtiene el transcript completo para que el agente genere el reporte
|
|
10
5
|
*/
|
|
11
6
|
|
|
12
7
|
import type { Tool } from "../types.ts";
|
|
13
|
-
import {
|
|
8
|
+
import { getHiveDB } from "../../storage/HiveDBStorage.ts";
|
|
14
9
|
import { voiceService, type AudioInput } from "../../voice/index.ts";
|
|
15
10
|
import { logger } from "../../utils/logger.ts";
|
|
16
11
|
|
|
17
12
|
const log = logger.child("meeting");
|
|
18
13
|
|
|
14
|
+
interface MeetingSession {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
status: string;
|
|
18
|
+
sttModel: string;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
stoppedAt?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MeetingSegment {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
seq: number;
|
|
26
|
+
speaker?: string;
|
|
27
|
+
text: string;
|
|
28
|
+
durationMs?: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
// ─── meeting_start ───────────────────────────────────────────────────────────
|
|
20
33
|
|
|
21
34
|
export const meetingStartTool: Tool = {
|
|
@@ -43,30 +56,27 @@ export const meetingStartTool: Tool = {
|
|
|
43
56
|
(params.stt_model as string) || "whisper-large-v3-turbo";
|
|
44
57
|
|
|
45
58
|
try {
|
|
46
|
-
const db =
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
title: string;
|
|
56
|
-
status: string;
|
|
57
|
-
stt_model: string;
|
|
58
|
-
started_at: number;
|
|
59
|
+
const db = await getHiveDB();
|
|
60
|
+
const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
|
|
61
|
+
const id = crypto.randomUUID();
|
|
62
|
+
const session: MeetingSession = {
|
|
63
|
+
id,
|
|
64
|
+
title,
|
|
65
|
+
status: "active",
|
|
66
|
+
sttModel,
|
|
67
|
+
startedAt: Math.floor(Date.now() / 1000),
|
|
59
68
|
};
|
|
69
|
+
await sessionsCol.put(id, session);
|
|
60
70
|
|
|
61
|
-
log.info(`Meeting session started: ${
|
|
71
|
+
log.info(`Meeting session started: ${id} — "${title}"`);
|
|
62
72
|
|
|
63
73
|
return {
|
|
64
74
|
ok: true,
|
|
65
|
-
session_id:
|
|
66
|
-
title
|
|
67
|
-
status:
|
|
68
|
-
stt_model:
|
|
69
|
-
message: `✅ Sesión de reunión iniciada. ID: ${
|
|
75
|
+
session_id: id,
|
|
76
|
+
title,
|
|
77
|
+
status: session.status,
|
|
78
|
+
stt_model: sttModel,
|
|
79
|
+
message: `✅ Sesión de reunión iniciada. ID: ${id}\nTítulo: "${title}"\nModelo STT: ${sttModel}`,
|
|
70
80
|
};
|
|
71
81
|
} catch (error) {
|
|
72
82
|
log.error(`meeting_start error: ${(error as Error).message}`);
|
|
@@ -106,21 +116,19 @@ export const meetingAddSegmentTool: Tool = {
|
|
|
106
116
|
execute: async (params) => {
|
|
107
117
|
const sessionId = params.session_id as string;
|
|
108
118
|
const audioBase64 = params.audio_base64 as string;
|
|
109
|
-
const speaker = (params.speaker as string) ||
|
|
119
|
+
const speaker = (params.speaker as string) || undefined;
|
|
110
120
|
const mimeType = (params.mime_type as string) || "audio/webm";
|
|
111
121
|
|
|
112
122
|
try {
|
|
113
|
-
const db =
|
|
123
|
+
const db = await getHiveDB();
|
|
124
|
+
const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
|
|
125
|
+
const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
|
|
114
126
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
`SELECT id, stt_model, status FROM meeting_sessions WHERE id = ?`
|
|
118
|
-
)
|
|
119
|
-
.get(sessionId) as { id: string; stt_model: string; status: string } | undefined;
|
|
120
|
-
|
|
121
|
-
if (!session) {
|
|
127
|
+
const sessionEntry = await sessionsCol.get(sessionId);
|
|
128
|
+
if (!sessionEntry) {
|
|
122
129
|
return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
|
|
123
130
|
}
|
|
131
|
+
const session = sessionEntry.doc;
|
|
124
132
|
if (session.status !== "active") {
|
|
125
133
|
return {
|
|
126
134
|
ok: false,
|
|
@@ -136,7 +144,7 @@ export const meetingAddSegmentTool: Tool = {
|
|
|
136
144
|
|
|
137
145
|
let transcription: string;
|
|
138
146
|
try {
|
|
139
|
-
transcription = await voiceService.transcribe(audioInput, session.
|
|
147
|
+
transcription = await voiceService.transcribe(audioInput, session.sttModel);
|
|
140
148
|
} catch (transcribeError) {
|
|
141
149
|
return {
|
|
142
150
|
ok: false,
|
|
@@ -144,18 +152,19 @@ export const meetingAddSegmentTool: Tool = {
|
|
|
144
152
|
};
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)
|
|
151
|
-
.get(sessionId) as { next_seq: number };
|
|
152
|
-
|
|
153
|
-
const seq = seqResult.next_seq;
|
|
155
|
+
const entries = await segmentsCol.scan();
|
|
156
|
+
const seq = entries
|
|
157
|
+
.filter(e => e.doc.sessionId === sessionId)
|
|
158
|
+
.reduce((max, e) => Math.max(max, e.doc.seq), -1) + 1;
|
|
154
159
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
const segmentId = `${sessionId}:${seq}`;
|
|
161
|
+
await segmentsCol.put(segmentId, {
|
|
162
|
+
sessionId,
|
|
163
|
+
seq,
|
|
164
|
+
speaker,
|
|
165
|
+
text: transcription,
|
|
166
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
167
|
+
});
|
|
159
168
|
|
|
160
169
|
log.info(`Segment ${seq} added to session ${sessionId}: "${transcription.substring(0, 60)}..."`);
|
|
161
170
|
|
|
@@ -196,15 +205,15 @@ export const meetingStopTool: Tool = {
|
|
|
196
205
|
const sessionId = params.session_id as string;
|
|
197
206
|
|
|
198
207
|
try {
|
|
199
|
-
const db =
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
.query(`SELECT id, title, status FROM meeting_sessions WHERE id = ?`)
|
|
203
|
-
.get(sessionId) as { id: string; title: string; status: string } | undefined;
|
|
208
|
+
const db = await getHiveDB();
|
|
209
|
+
const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
|
|
210
|
+
const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
|
|
204
211
|
|
|
205
|
-
|
|
212
|
+
const sessionEntry = await sessionsCol.get(sessionId);
|
|
213
|
+
if (!sessionEntry) {
|
|
206
214
|
return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
|
|
207
215
|
}
|
|
216
|
+
const session = sessionEntry.doc;
|
|
208
217
|
if (session.status === "stopped" || session.status === "report_ready") {
|
|
209
218
|
return {
|
|
210
219
|
ok: true,
|
|
@@ -213,22 +222,20 @@ export const meetingStopTool: Tool = {
|
|
|
213
222
|
};
|
|
214
223
|
}
|
|
215
224
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
).run(sessionId);
|
|
225
|
+
const stoppedAt = Math.floor(Date.now() / 1000);
|
|
226
|
+
await sessionsCol.put(sessionId, { ...session, status: "stopped", stoppedAt });
|
|
219
227
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
.get(sessionId) as { count: number };
|
|
228
|
+
const entries = await segmentsCol.scan();
|
|
229
|
+
const count = entries.filter(e => e.doc.sessionId === sessionId).length;
|
|
223
230
|
|
|
224
|
-
log.info(`Meeting session stopped: ${sessionId} — ${
|
|
231
|
+
log.info(`Meeting session stopped: ${sessionId} — ${count} segments`);
|
|
225
232
|
|
|
226
233
|
return {
|
|
227
234
|
ok: true,
|
|
228
235
|
session_id: sessionId,
|
|
229
236
|
title: session.title,
|
|
230
|
-
segment_count:
|
|
231
|
-
message: `⏹️ Sesión "${session.title}" detenida.\n${
|
|
237
|
+
segment_count: count,
|
|
238
|
+
message: `⏹️ Sesión "${session.title}" detenida.\n${count} segmentos transcritos.\n\nPuedes pedir el reporte con: "Genera el reporte de la reunión ${sessionId}"`,
|
|
232
239
|
};
|
|
233
240
|
} catch (error) {
|
|
234
241
|
log.error(`meeting_stop error: ${(error as Error).message}`);
|
|
@@ -257,38 +264,21 @@ export const meetingReportTool: Tool = {
|
|
|
257
264
|
const sessionId = params.session_id as string;
|
|
258
265
|
|
|
259
266
|
try {
|
|
260
|
-
const db =
|
|
261
|
-
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
267
|
-
.get(sessionId) as {
|
|
268
|
-
id: string;
|
|
269
|
-
title: string;
|
|
270
|
-
status: string;
|
|
271
|
-
stt_model: string;
|
|
272
|
-
started_at: number;
|
|
273
|
-
stopped_at: number | null;
|
|
274
|
-
} | undefined;
|
|
275
|
-
|
|
276
|
-
if (!session) {
|
|
267
|
+
const db = await getHiveDB();
|
|
268
|
+
const sessionsCol = db.collection<MeetingSession>("meeting_sessions");
|
|
269
|
+
const segmentsCol = db.collection<MeetingSegment>("meeting_segments");
|
|
270
|
+
|
|
271
|
+
const sessionEntry = await sessionsCol.get(sessionId);
|
|
272
|
+
if (!sessionEntry) {
|
|
277
273
|
return { ok: false, error: `Sesión ${sessionId} no encontrada.` };
|
|
278
274
|
}
|
|
275
|
+
const session = sessionEntry.doc;
|
|
279
276
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
)
|
|
286
|
-
.all(sessionId) as {
|
|
287
|
-
seq: number;
|
|
288
|
-
speaker: string | null;
|
|
289
|
-
text: string;
|
|
290
|
-
created_at: number;
|
|
291
|
-
}[];
|
|
277
|
+
const entries = await segmentsCol.scan();
|
|
278
|
+
const segments = entries
|
|
279
|
+
.filter(e => e.doc.sessionId === sessionId)
|
|
280
|
+
.map(e => e.doc)
|
|
281
|
+
.sort((a, b) => a.seq - b.seq);
|
|
292
282
|
|
|
293
283
|
if (segments.length === 0) {
|
|
294
284
|
return {
|
|
@@ -301,9 +291,9 @@ export const meetingReportTool: Tool = {
|
|
|
301
291
|
.map((s) => (s.speaker ? `[${s.speaker}]: ${s.text}` : s.text))
|
|
302
292
|
.join("\n");
|
|
303
293
|
|
|
304
|
-
const durationSec = session.
|
|
305
|
-
? session.
|
|
306
|
-
: Math.floor(Date.now() / 1000) - session.
|
|
294
|
+
const durationSec = session.stoppedAt
|
|
295
|
+
? session.stoppedAt - session.startedAt
|
|
296
|
+
: Math.floor(Date.now() / 1000) - session.startedAt;
|
|
307
297
|
const durationMin = Math.floor(durationSec / 60);
|
|
308
298
|
const durationSecRem = durationSec % 60;
|
|
309
299
|
|
|
@@ -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
|
+
};
|