ak-gemini 1.2.0 → 2.0.1

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,312 @@
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.writeDir = options.writeDir || null;
83
+ this._stopped = false;
84
+
85
+ // ── Apply tools to chat config ──
86
+ if (this.tools.length > 0) {
87
+ this.chatConfig.tools = [{ functionDeclarations: this.tools }];
88
+ this.chatConfig.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
89
+ }
90
+
91
+ log.debug(`ToolAgent created with ${this.tools.length} tools`);
92
+ }
93
+
94
+ // ── Non-Streaming Chat ───────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Send a message and get a complete response (non-streaming).
98
+ * Automatically handles the tool-use loop.
99
+ *
100
+ * @param {string} message - The user's message
101
+ * @param {Object} [opts={}] - Per-message options
102
+ * @param {Record<string, string>} [opts.labels] - Per-message billing labels
103
+ * @returns {Promise<AgentResponse>} Response with text, toolCalls, and usage
104
+ */
105
+ async chat(message, opts = {}) {
106
+ if (!this.chatSession) await this.init();
107
+ this._stopped = false;
108
+
109
+ const allToolCalls = [];
110
+
111
+ let response = await this.chatSession.sendMessage({ message });
112
+
113
+ for (let round = 0; round < this.maxToolRounds; round++) {
114
+ if (this._stopped) break;
115
+
116
+ const functionCalls = response.functionCalls;
117
+ if (!functionCalls || functionCalls.length === 0) break;
118
+
119
+ const toolResults = await Promise.all(
120
+ functionCalls.map(async (call) => {
121
+ // Fire onToolCall callback
122
+ if (this.onToolCall) {
123
+ try { this.onToolCall(call.name, call.args); }
124
+ catch (e) { log.warn(`onToolCall callback error: ${e.message}`); }
125
+ }
126
+
127
+ // Check onBeforeExecution gate
128
+ if (this.onBeforeExecution) {
129
+ try {
130
+ const allowed = await this.onBeforeExecution(call.name, call.args);
131
+ if (allowed === false) {
132
+ const result = { error: 'Execution denied by onBeforeExecution callback' };
133
+ allToolCalls.push({ name: call.name, args: call.args, result });
134
+ return { id: call.id, name: call.name, result };
135
+ }
136
+ } catch (e) {
137
+ log.warn(`onBeforeExecution callback error: ${e.message}`);
138
+ }
139
+ }
140
+
141
+ let result;
142
+ try {
143
+ result = await this.toolExecutor(call.name, call.args);
144
+ } catch (err) {
145
+ log.warn(`Tool ${call.name} failed: ${err.message}`);
146
+ result = { error: err.message };
147
+ }
148
+
149
+ allToolCalls.push({ name: call.name, args: call.args, result });
150
+
151
+ return { id: call.id, name: call.name, result };
152
+ })
153
+ );
154
+
155
+ // Send function responses back to the model
156
+ response = await this.chatSession.sendMessage({
157
+ message: toolResults.map(r => ({
158
+ functionResponse: {
159
+ id: r.id,
160
+ name: r.name,
161
+ response: { output: r.result }
162
+ }
163
+ }))
164
+ });
165
+ }
166
+
167
+ this._captureMetadata(response);
168
+
169
+ // Set cumulative usage
170
+ this._cumulativeUsage = {
171
+ promptTokens: this.lastResponseMetadata.promptTokens,
172
+ responseTokens: this.lastResponseMetadata.responseTokens,
173
+ totalTokens: this.lastResponseMetadata.totalTokens,
174
+ attempts: 1
175
+ };
176
+
177
+ return {
178
+ text: response.text || '',
179
+ toolCalls: allToolCalls,
180
+ usage: this.getLastUsage()
181
+ };
182
+ }
183
+
184
+ // ── Streaming ────────────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Send a message and stream the response as events.
188
+ * Automatically handles the tool-use loop between streamed rounds.
189
+ *
190
+ * Event types:
191
+ * - `text` — A chunk of the agent's text response
192
+ * - `tool_call` — The agent is about to call a tool
193
+ * - `tool_result` — A tool finished executing
194
+ * - `done` — The agent finished
195
+ *
196
+ * @param {string} message - The user's message
197
+ * @param {Object} [opts={}] - Per-message options
198
+ * @yields {AgentStreamEvent}
199
+ */
200
+ async *stream(message, opts = {}) {
201
+ if (!this.chatSession) await this.init();
202
+ this._stopped = false;
203
+
204
+ const allToolCalls = [];
205
+ let fullText = '';
206
+
207
+ let streamResponse = await this.chatSession.sendMessageStream({ message });
208
+
209
+ for (let round = 0; round < this.maxToolRounds; round++) {
210
+ if (this._stopped) break;
211
+
212
+ let roundText = '';
213
+ const functionCalls = [];
214
+
215
+ // Consume the stream
216
+ for await (const chunk of streamResponse) {
217
+ if (chunk.functionCalls) {
218
+ functionCalls.push(...chunk.functionCalls);
219
+ } else if (chunk.candidates?.[0]?.content?.parts?.[0]?.text) {
220
+ const text = chunk.candidates[0].content.parts[0].text;
221
+ roundText += text;
222
+ fullText += text;
223
+ yield { type: 'text', text };
224
+ }
225
+ }
226
+
227
+ // No tool calls — we're done
228
+ if (functionCalls.length === 0) {
229
+ yield {
230
+ type: 'done',
231
+ fullText,
232
+ usage: this.getLastUsage()
233
+ };
234
+ return;
235
+ }
236
+
237
+ // Execute tools sequentially so we can yield events
238
+ const toolResults = [];
239
+ for (const call of functionCalls) {
240
+ if (this._stopped) break;
241
+
242
+ yield { type: 'tool_call', toolName: call.name, args: call.args };
243
+
244
+ // Fire onToolCall callback
245
+ if (this.onToolCall) {
246
+ try { this.onToolCall(call.name, call.args); }
247
+ catch (e) { log.warn(`onToolCall callback error: ${e.message}`); }
248
+ }
249
+
250
+ // Check onBeforeExecution gate
251
+ let denied = false;
252
+ if (this.onBeforeExecution) {
253
+ try {
254
+ const allowed = await this.onBeforeExecution(call.name, call.args);
255
+ if (allowed === false) denied = true;
256
+ } catch (e) {
257
+ log.warn(`onBeforeExecution callback error: ${e.message}`);
258
+ }
259
+ }
260
+
261
+ let result;
262
+ if (denied) {
263
+ result = { error: 'Execution denied by onBeforeExecution callback' };
264
+ } else {
265
+ try {
266
+ result = await this.toolExecutor(call.name, call.args);
267
+ } catch (err) {
268
+ log.warn(`Tool ${call.name} failed: ${err.message}`);
269
+ result = { error: err.message };
270
+ }
271
+ }
272
+
273
+ allToolCalls.push({ name: call.name, args: call.args, result });
274
+ yield { type: 'tool_result', toolName: call.name, result };
275
+
276
+ toolResults.push({ id: call.id, name: call.name, result });
277
+ }
278
+
279
+ // Send function responses back and get next stream
280
+ streamResponse = await this.chatSession.sendMessageStream({
281
+ message: toolResults.map(r => ({
282
+ functionResponse: {
283
+ id: r.id,
284
+ name: r.name,
285
+ response: { output: r.result }
286
+ }
287
+ }))
288
+ });
289
+ }
290
+
291
+ // Max rounds reached or stopped
292
+ yield {
293
+ type: 'done',
294
+ fullText,
295
+ usage: this.getLastUsage(),
296
+ warning: this._stopped ? 'Agent was stopped' : 'Max tool rounds reached'
297
+ };
298
+ }
299
+ // ── Stop ────────────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Stop the agent before the next tool execution round.
303
+ * If called during a chat() or stream() loop, the agent will finish
304
+ * the current round and then stop.
305
+ */
306
+ stop() {
307
+ this._stopped = true;
308
+ log.info('ToolAgent stopped');
309
+ }
310
+ }
311
+
312
+ export default ToolAgent;