ak-gemini 1.2.0 → 2.0.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/tool-agent.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @fileoverview ToolAgent class — AI agent with user-provided tools.
3
+ * Extends BaseGemini with automatic tool-use loops for both streaming
4
+ * and non-streaming conversations.
5
+ */
6
+
7
+ import BaseGemini from './base.js';
8
+ import log from './logger.js';
9
+
10
+ /**
11
+ * @typedef {import('./types').ToolAgentOptions} ToolAgentOptions
12
+ * @typedef {import('./types').AgentResponse} AgentResponse
13
+ * @typedef {import('./types').AgentStreamEvent} AgentStreamEvent
14
+ */
15
+
16
+ /**
17
+ * AI agent that uses user-provided tools to accomplish tasks.
18
+ * Automatically manages the tool-use loop: when the model decides to call
19
+ * a tool, the agent executes it via your toolExecutor, sends the result back,
20
+ * and continues until the model produces a final text response.
21
+ *
22
+ * Ships with zero built-in tools — you provide everything via the constructor.
23
+ *
24
+ * @example
25
+ * ```javascript
26
+ * import { ToolAgent } from 'ak-gemini';
27
+ *
28
+ * const agent = new ToolAgent({
29
+ * systemPrompt: 'You are a research assistant.',
30
+ * tools: [
31
+ * {
32
+ * name: 'http_get',
33
+ * description: 'Fetch a URL and return its contents',
34
+ * parametersJsonSchema: {
35
+ * type: 'object',
36
+ * properties: { url: { type: 'string', description: 'The URL to fetch' } },
37
+ * required: ['url']
38
+ * }
39
+ * }
40
+ * ],
41
+ * toolExecutor: async (toolName, args) => {
42
+ * if (toolName === 'http_get') {
43
+ * const res = await fetch(args.url);
44
+ * return { status: res.status, body: await res.text() };
45
+ * }
46
+ * throw new Error(`Unknown tool: ${toolName}`);
47
+ * }
48
+ * });
49
+ *
50
+ * const result = await agent.chat('Fetch https://api.example.com/data and summarize it');
51
+ * console.log(result.text); // Agent's summary
52
+ * console.log(result.toolCalls); // [{ name: 'http_get', args: {...}, result: {...} }]
53
+ * ```
54
+ */
55
+ class ToolAgent extends BaseGemini {
56
+ /**
57
+ * @param {ToolAgentOptions} [options={}]
58
+ */
59
+ constructor(options = {}) {
60
+ if (options.systemPrompt === undefined) {
61
+ options = { ...options, systemPrompt: 'You are a helpful AI assistant.' };
62
+ }
63
+
64
+ super(options);
65
+
66
+ // ── Tools ──
67
+ this.tools = options.tools || [];
68
+ this.toolExecutor = options.toolExecutor || null;
69
+
70
+ // Validate: if tools provided, executor is required (and vice versa)
71
+ if (this.tools.length > 0 && !this.toolExecutor) {
72
+ throw new Error("ToolAgent: tools provided without a toolExecutor. Provide a toolExecutor function to handle tool calls.");
73
+ }
74
+ if (this.toolExecutor && this.tools.length === 0) {
75
+ throw new Error("ToolAgent: toolExecutor provided without tools. Provide tool declarations so the model knows what tools are available.");
76
+ }
77
+
78
+ // ── Tool loop config ──
79
+ this.maxToolRounds = options.maxToolRounds || 10;
80
+ this.onToolCall = options.onToolCall || null;
81
+ this.onBeforeExecution = options.onBeforeExecution || null;
82
+ this._stopped = false;
83
+
84
+ // ── Apply tools to chat config ──
85
+ if (this.tools.length > 0) {
86
+ this.chatConfig.tools = [{ functionDeclarations: this.tools }];
87
+ this.chatConfig.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
88
+ }
89
+
90
+ log.debug(`ToolAgent created with ${this.tools.length} tools`);
91
+ }
92
+
93
+ // ── Non-Streaming Chat ───────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Send a message and get a complete response (non-streaming).
97
+ * Automatically handles the tool-use loop.
98
+ *
99
+ * @param {string} message - The user's message
100
+ * @param {Object} [opts={}] - Per-message options
101
+ * @param {Record<string, string>} [opts.labels] - Per-message billing labels
102
+ * @returns {Promise<AgentResponse>} Response with text, toolCalls, and usage
103
+ */
104
+ async chat(message, opts = {}) {
105
+ if (!this.chatSession) await this.init();
106
+ this._stopped = false;
107
+
108
+ const allToolCalls = [];
109
+
110
+ let response = await this.chatSession.sendMessage({ message });
111
+
112
+ for (let round = 0; round < this.maxToolRounds; round++) {
113
+ if (this._stopped) break;
114
+
115
+ const functionCalls = response.functionCalls;
116
+ if (!functionCalls || functionCalls.length === 0) break;
117
+
118
+ const toolResults = await Promise.all(
119
+ functionCalls.map(async (call) => {
120
+ // Fire onToolCall callback
121
+ if (this.onToolCall) {
122
+ try { this.onToolCall(call.name, call.args); }
123
+ catch (e) { log.warn(`onToolCall callback error: ${e.message}`); }
124
+ }
125
+
126
+ // Check onBeforeExecution gate
127
+ if (this.onBeforeExecution) {
128
+ try {
129
+ const allowed = await this.onBeforeExecution(call.name, call.args);
130
+ if (allowed === false) {
131
+ const result = { error: 'Execution denied by onBeforeExecution callback' };
132
+ allToolCalls.push({ name: call.name, args: call.args, result });
133
+ return { id: call.id, name: call.name, result };
134
+ }
135
+ } catch (e) {
136
+ log.warn(`onBeforeExecution callback error: ${e.message}`);
137
+ }
138
+ }
139
+
140
+ let result;
141
+ try {
142
+ result = await this.toolExecutor(call.name, call.args);
143
+ } catch (err) {
144
+ log.warn(`Tool ${call.name} failed: ${err.message}`);
145
+ result = { error: err.message };
146
+ }
147
+
148
+ allToolCalls.push({ name: call.name, args: call.args, result });
149
+
150
+ return { id: call.id, name: call.name, result };
151
+ })
152
+ );
153
+
154
+ // Send function responses back to the model
155
+ response = await this.chatSession.sendMessage({
156
+ message: toolResults.map(r => ({
157
+ functionResponse: {
158
+ id: r.id,
159
+ name: r.name,
160
+ response: { output: r.result }
161
+ }
162
+ }))
163
+ });
164
+ }
165
+
166
+ this._captureMetadata(response);
167
+
168
+ // Set cumulative usage
169
+ this._cumulativeUsage = {
170
+ promptTokens: this.lastResponseMetadata.promptTokens,
171
+ responseTokens: this.lastResponseMetadata.responseTokens,
172
+ totalTokens: this.lastResponseMetadata.totalTokens,
173
+ attempts: 1
174
+ };
175
+
176
+ return {
177
+ text: response.text || '',
178
+ toolCalls: allToolCalls,
179
+ usage: this.getLastUsage()
180
+ };
181
+ }
182
+
183
+ // ── Streaming ────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Send a message and stream the response as events.
187
+ * Automatically handles the tool-use loop between streamed rounds.
188
+ *
189
+ * Event types:
190
+ * - `text` — A chunk of the agent's text response
191
+ * - `tool_call` — The agent is about to call a tool
192
+ * - `tool_result` — A tool finished executing
193
+ * - `done` — The agent finished
194
+ *
195
+ * @param {string} message - The user's message
196
+ * @param {Object} [opts={}] - Per-message options
197
+ * @yields {AgentStreamEvent}
198
+ */
199
+ async *stream(message, opts = {}) {
200
+ if (!this.chatSession) await this.init();
201
+ this._stopped = false;
202
+
203
+ const allToolCalls = [];
204
+ let fullText = '';
205
+
206
+ let streamResponse = await this.chatSession.sendMessageStream({ message });
207
+
208
+ for (let round = 0; round < this.maxToolRounds; round++) {
209
+ if (this._stopped) break;
210
+
211
+ let roundText = '';
212
+ const functionCalls = [];
213
+
214
+ // Consume the stream
215
+ for await (const chunk of streamResponse) {
216
+ if (chunk.functionCalls) {
217
+ functionCalls.push(...chunk.functionCalls);
218
+ } else if (chunk.candidates?.[0]?.content?.parts?.[0]?.text) {
219
+ const text = chunk.candidates[0].content.parts[0].text;
220
+ roundText += text;
221
+ fullText += text;
222
+ yield { type: 'text', text };
223
+ }
224
+ }
225
+
226
+ // No tool calls — we're done
227
+ if (functionCalls.length === 0) {
228
+ yield {
229
+ type: 'done',
230
+ fullText,
231
+ usage: this.getLastUsage()
232
+ };
233
+ return;
234
+ }
235
+
236
+ // Execute tools sequentially so we can yield events
237
+ const toolResults = [];
238
+ for (const call of functionCalls) {
239
+ if (this._stopped) break;
240
+
241
+ yield { type: 'tool_call', toolName: call.name, args: call.args };
242
+
243
+ // Fire onToolCall callback
244
+ if (this.onToolCall) {
245
+ try { this.onToolCall(call.name, call.args); }
246
+ catch (e) { log.warn(`onToolCall callback error: ${e.message}`); }
247
+ }
248
+
249
+ // Check onBeforeExecution gate
250
+ let denied = false;
251
+ if (this.onBeforeExecution) {
252
+ try {
253
+ const allowed = await this.onBeforeExecution(call.name, call.args);
254
+ if (allowed === false) denied = true;
255
+ } catch (e) {
256
+ log.warn(`onBeforeExecution callback error: ${e.message}`);
257
+ }
258
+ }
259
+
260
+ let result;
261
+ if (denied) {
262
+ result = { error: 'Execution denied by onBeforeExecution callback' };
263
+ } else {
264
+ try {
265
+ result = await this.toolExecutor(call.name, call.args);
266
+ } catch (err) {
267
+ log.warn(`Tool ${call.name} failed: ${err.message}`);
268
+ result = { error: err.message };
269
+ }
270
+ }
271
+
272
+ allToolCalls.push({ name: call.name, args: call.args, result });
273
+ yield { type: 'tool_result', toolName: call.name, result };
274
+
275
+ toolResults.push({ id: call.id, name: call.name, result });
276
+ }
277
+
278
+ // Send function responses back and get next stream
279
+ streamResponse = await this.chatSession.sendMessageStream({
280
+ message: toolResults.map(r => ({
281
+ functionResponse: {
282
+ id: r.id,
283
+ name: r.name,
284
+ response: { output: r.result }
285
+ }
286
+ }))
287
+ });
288
+ }
289
+
290
+ // Max rounds reached or stopped
291
+ yield {
292
+ type: 'done',
293
+ fullText,
294
+ usage: this.getLastUsage(),
295
+ warning: this._stopped ? 'Agent was stopped' : 'Max tool rounds reached'
296
+ };
297
+ }
298
+ // ── Stop ────────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Stop the agent before the next tool execution round.
302
+ * If called during a chat() or stream() loop, the agent will finish
303
+ * the current round and then stop.
304
+ */
305
+ stop() {
306
+ this._stopped = true;
307
+ log.info('ToolAgent stopped');
308
+ }
309
+ }
310
+
311
+ export default ToolAgent;