gx402 1.4.0 → 1.5.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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "gx402",
3
3
  "module": "src/index.ts",
4
4
  "main": "./src/index.ts",
5
- "version": "1.4.0",
5
+ "version": "1.5.0",
6
6
  "type": "module",
7
7
  "private": false,
8
8
  "bin": {
package/src/index.ts CHANGED
@@ -31,5 +31,6 @@ export { generateRequestId } from './utils';
31
31
  // Gemini multimodal
32
32
  export { gemini, generateImage, generateVideo, generateMusic, deepResearch } from './gemini/multimodal';
33
33
 
34
- // Loop Agent → now lives in packages/smart-agent
35
- // export * from './loop';
34
+ // Loop Agent
35
+ export { LoopAgent } from './loop';
36
+ export type { LoopConfig, LoopOutcome, LoopState, LoopEvent, LoopEventCallback, LoopResult, ToolCall, ToolResult, OutcomeResult } from './loop';
package/src/loop.ts ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * LoopAgent — Self-healing agentic loop
3
+ *
4
+ * Iteratively calls an LLM with tool access (write_file, exec, read_file)
5
+ * and checks user-defined outcome predicates after each iteration.
6
+ * Stops when all outcomes are met or maxIterations is reached.
7
+ *
8
+ * Usage:
9
+ * const agent = new LoopAgent({ llm, outcomes: [...], maxIterations: 10 })
10
+ * const result = await agent.execute("Build a script that...", onEvent)
11
+ */
12
+ import { callLLM } from './inference';
13
+ import type { LLMType } from './types';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+
17
+ // ─── Types ──────────────────────────────────────────────
18
+
19
+ export interface LoopOutcome {
20
+ description: string;
21
+ validate: (state: LoopState) => Promise<{ met: boolean; reason: string }>;
22
+ }
23
+
24
+ export interface LoopConfig {
25
+ llm: LLMType | string;
26
+ systemPrompt?: string;
27
+ cwd?: string;
28
+ maxIterations?: number;
29
+ confidenceThreshold?: number;
30
+ temperature?: number;
31
+ maxTokens?: number;
32
+ outcomes: LoopOutcome[];
33
+ }
34
+
35
+ export interface ToolCall {
36
+ tool: string;
37
+ params: Record<string, any>;
38
+ result?: ToolResult;
39
+ }
40
+
41
+ export interface ToolResult {
42
+ success: boolean;
43
+ output?: string;
44
+ error?: string;
45
+ }
46
+
47
+ export interface LoopState {
48
+ iteration: number;
49
+ toolHistory: ToolCall[];
50
+ outcomeResults: OutcomeResult[];
51
+ }
52
+
53
+ export interface OutcomeResult {
54
+ outcome: string;
55
+ met: boolean;
56
+ confidence: number;
57
+ reason: string;
58
+ }
59
+
60
+ export type LoopEvent =
61
+ | { type: 'iteration_start'; iteration: number }
62
+ | { type: 'tool_start'; tool: string; params: Record<string, any> }
63
+ | { type: 'tool_result'; tool: string; result: ToolResult }
64
+ | { type: 'outcome_check'; outcomes: OutcomeResult[] }
65
+ | { type: 'complete'; iteration: number; totalElapsedMs: number }
66
+ | { type: 'max_iterations_reached'; iteration: number }
67
+ | { type: 'error'; error: string };
68
+
69
+ export type LoopEventCallback = (event: LoopEvent) => void;
70
+
71
+ export interface LoopResult {
72
+ success: boolean;
73
+ iterations: number;
74
+ elapsedMs: number;
75
+ state: LoopState;
76
+ }
77
+
78
+ // ─── Built-in Tools ─────────────────────────────────────
79
+
80
+ function executeWriteFile(params: { path: string; content: string }, cwd: string): ToolResult {
81
+ try {
82
+ const filePath = path.isAbsolute(params.path)
83
+ ? params.path
84
+ : path.resolve(cwd, params.path);
85
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
86
+ fs.writeFileSync(filePath, params.content, 'utf-8');
87
+ return { success: true, output: `Wrote ${params.content.length} bytes to ${filePath}` };
88
+ } catch (e: any) {
89
+ return { success: false, error: e.message };
90
+ }
91
+ }
92
+
93
+ function executeReadFile(params: { path: string }, cwd: string): ToolResult {
94
+ try {
95
+ const filePath = path.isAbsolute(params.path)
96
+ ? params.path
97
+ : path.resolve(cwd, params.path);
98
+ if (!fs.existsSync(filePath)) {
99
+ return { success: false, error: `File not found: ${filePath}` };
100
+ }
101
+ const content = fs.readFileSync(filePath, 'utf-8');
102
+ return { success: true, output: content.substring(0, 10000) };
103
+ } catch (e: any) {
104
+ return { success: false, error: e.message };
105
+ }
106
+ }
107
+
108
+ async function executeCommand(params: { command: string }, cwd: string): Promise<ToolResult> {
109
+ try {
110
+ const proc = Bun.spawn(['sh', '-c', params.command], {
111
+ cwd,
112
+ stdout: 'pipe',
113
+ stderr: 'pipe',
114
+ });
115
+ const stdout = await new Response(proc.stdout).text();
116
+ const stderr = await new Response(proc.stderr).text();
117
+ const exitCode = await proc.exited;
118
+ const output = (stdout + (stderr ? `\n[stderr] ${stderr}` : '')).substring(0, 10000);
119
+ return {
120
+ success: exitCode === 0,
121
+ output,
122
+ error: exitCode !== 0 ? `Exit code: ${exitCode}` : undefined,
123
+ };
124
+ } catch (e: any) {
125
+ // Fallback for Windows (no sh)
126
+ try {
127
+ const proc = Bun.spawn(['cmd', '/c', params.command], {
128
+ cwd,
129
+ stdout: 'pipe',
130
+ stderr: 'pipe',
131
+ });
132
+ const stdout = await new Response(proc.stdout).text();
133
+ const stderr = await new Response(proc.stderr).text();
134
+ const exitCode = await proc.exited;
135
+ const output = (stdout + (stderr ? `\n[stderr] ${stderr}` : '')).substring(0, 10000);
136
+ return {
137
+ success: exitCode === 0,
138
+ output,
139
+ error: exitCode !== 0 ? `Exit code: ${exitCode}` : undefined,
140
+ };
141
+ } catch (e2: any) {
142
+ return { success: false, error: e2.message };
143
+ }
144
+ }
145
+ }
146
+
147
+ async function executeTool(name: string, params: Record<string, any>, cwd: string): Promise<ToolResult> {
148
+ switch (name) {
149
+ case 'write_file': return executeWriteFile(params as any, cwd);
150
+ case 'read_file': return executeReadFile(params as any, cwd);
151
+ case 'exec': return executeCommand(params as any, cwd);
152
+ default: return { success: false, error: `Unknown tool: ${name}` };
153
+ }
154
+ }
155
+
156
+ // ─── LLM Prompt ─────────────────────────────────────────
157
+
158
+ const TOOL_DOCS = `You have the following tools:
159
+
160
+ 1. write_file(path, content) — Write content to a file. Creates directories automatically.
161
+ 2. read_file(path) — Read a file's content.
162
+ 3. exec(command) — Run a shell command and get stdout/stderr.
163
+
164
+ To use a tool, respond with a JSON block inside <tool_call> tags:
165
+ <tool_call>
166
+ {"tool": "write_file", "params": {"path": "output/hello.ts", "content": "console.log('hello')"}}
167
+ </tool_call>
168
+
169
+ You can make multiple tool calls in one response. Each must be in its own <tool_call> block.
170
+ After tool results are returned, analyze them and decide if you need more actions.`;
171
+
172
+ function buildSystemPrompt(config: LoopConfig): string {
173
+ const parts = [
174
+ config.systemPrompt || 'You are a skilled developer. Complete the given task step by step.',
175
+ '',
176
+ TOOL_DOCS,
177
+ '',
178
+ '## Outcomes to achieve:',
179
+ ...config.outcomes.map((o, i) => `${i + 1}. ${o.description}`),
180
+ '',
181
+ 'Work iteratively. Use tools to make progress, then check results. Fix any errors you encounter.',
182
+ ];
183
+ return parts.join('\n');
184
+ }
185
+
186
+ function buildIterationPrompt(state: LoopState, task: string): string {
187
+ const parts = [task];
188
+
189
+ if (state.toolHistory.length > 0) {
190
+ parts.push('\n\n## Previous tool results:');
191
+ for (const call of state.toolHistory.slice(-10)) { // Last 10 calls
192
+ parts.push(`\n[${call.tool}] ${JSON.stringify(call.params)}`);
193
+ if (call.result) {
194
+ parts.push(call.result.success
195
+ ? `✅ ${call.result.output?.substring(0, 500) || 'OK'}`
196
+ : `❌ ${call.result.error || 'Failed'}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ if (state.outcomeResults.length > 0) {
202
+ parts.push('\n\n## Outcome status:');
203
+ for (const o of state.outcomeResults) {
204
+ parts.push(`${o.met ? '✅' : '❌'} ${o.outcome}: ${o.reason}`);
205
+ }
206
+ parts.push('\nFix any unmet outcomes.');
207
+ }
208
+
209
+ return parts.join('\n');
210
+ }
211
+
212
+ // ─── Tool Call Parser ───────────────────────────────────
213
+
214
+ function parseToolCalls(response: string): ToolCall[] {
215
+ const calls: ToolCall[] = [];
216
+ const regex = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
217
+ let match;
218
+ while ((match = regex.exec(response)) !== null) {
219
+ try {
220
+ const parsed = JSON.parse(match[1]!);
221
+ if (parsed.tool && parsed.params) {
222
+ calls.push({ tool: parsed.tool, params: parsed.params });
223
+ }
224
+ } catch {
225
+ // Skip malformed tool calls
226
+ }
227
+ }
228
+ return calls;
229
+ }
230
+
231
+ // ─── LoopAgent ──────────────────────────────────────────
232
+
233
+ export class LoopAgent {
234
+ private config: LoopConfig;
235
+
236
+ constructor(config: LoopConfig) {
237
+ if (!config.outcomes?.length) {
238
+ throw new Error('LoopAgent requires at least one outcome');
239
+ }
240
+ this.config = {
241
+ maxIterations: 10,
242
+ confidenceThreshold: 0.9,
243
+ temperature: 0.3,
244
+ maxTokens: 8000,
245
+ ...config,
246
+ };
247
+ }
248
+
249
+ async execute(task: string, onEvent?: LoopEventCallback): Promise<LoopResult> {
250
+ const startTime = Date.now();
251
+ const cwd = this.config.cwd || process.cwd();
252
+ const state: LoopState = {
253
+ iteration: 0,
254
+ toolHistory: [],
255
+ outcomeResults: [],
256
+ };
257
+
258
+ const systemPrompt = buildSystemPrompt(this.config);
259
+ const maxIter = this.config.maxIterations!;
260
+
261
+ for (let i = 0; i < maxIter; i++) {
262
+ state.iteration = i;
263
+ onEvent?.({ type: 'iteration_start', iteration: i });
264
+
265
+ try {
266
+ // 1. Call LLM
267
+ const userPrompt = buildIterationPrompt(state, task);
268
+ const response = await callLLM(
269
+ this.config.llm as string,
270
+ [
271
+ { role: 'system', content: systemPrompt },
272
+ { role: 'user', content: userPrompt },
273
+ ],
274
+ {
275
+ temperature: this.config.temperature,
276
+ maxTokens: this.config.maxTokens,
277
+ },
278
+ );
279
+
280
+ // 2. Parse tool calls from response
281
+ const toolCalls = parseToolCalls(response);
282
+
283
+ // 3. Execute each tool call
284
+ for (const call of toolCalls) {
285
+ onEvent?.({ type: 'tool_start', tool: call.tool, params: call.params });
286
+ call.result = await executeTool(call.tool, call.params, cwd);
287
+ state.toolHistory.push(call);
288
+ onEvent?.({ type: 'tool_result', tool: call.tool, result: call.result });
289
+ }
290
+
291
+ // 4. Check outcomes
292
+ state.outcomeResults = await this.checkOutcomes(state);
293
+ onEvent?.({ type: 'outcome_check', outcomes: state.outcomeResults });
294
+
295
+ // 5. All met?
296
+ const allMet = state.outcomeResults.every(o => o.met);
297
+ if (allMet) {
298
+ const elapsed = Date.now() - startTime;
299
+ onEvent?.({ type: 'complete', iteration: i, totalElapsedMs: elapsed });
300
+ return { success: true, iterations: i + 1, elapsedMs: elapsed, state };
301
+ }
302
+
303
+ // 6. No tool calls and no progress? Bail to avoid infinite no-ops
304
+ if (toolCalls.length === 0 && i > 0) {
305
+ // LLM didn't produce any tool calls — might be stuck
306
+ // Give it one more chance with a nudge
307
+ }
308
+
309
+ } catch (err: any) {
310
+ onEvent?.({ type: 'error', error: err.message });
311
+ }
312
+ }
313
+
314
+ // Max iterations reached
315
+ const elapsed = Date.now() - startTime;
316
+ onEvent?.({ type: 'max_iterations_reached', iteration: maxIter });
317
+ return { success: false, iterations: maxIter, elapsedMs: elapsed, state };
318
+ }
319
+
320
+ private async checkOutcomes(state: LoopState): Promise<OutcomeResult[]> {
321
+ const results: OutcomeResult[] = [];
322
+ for (const outcome of this.config.outcomes) {
323
+ try {
324
+ const { met, reason } = await outcome.validate(state);
325
+ results.push({
326
+ outcome: outcome.description,
327
+ met,
328
+ confidence: met ? 1.0 : 0.0,
329
+ reason,
330
+ });
331
+ } catch (err: any) {
332
+ results.push({
333
+ outcome: outcome.description,
334
+ met: false,
335
+ confidence: 0,
336
+ reason: `Validation error: ${err.message}`,
337
+ });
338
+ }
339
+ }
340
+ return results;
341
+ }
342
+ }