@agi-cli/server 0.1.79 → 0.1.81

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.
@@ -0,0 +1,160 @@
1
+ import type { Hono } from 'hono';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, relative } from 'node:path';
4
+ import { exec } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { serializeError } from '../runtime/api-error.ts';
7
+ import { logger } from '../runtime/logger.ts';
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ const EXCLUDED_PATTERNS = [
12
+ 'node_modules',
13
+ '.git',
14
+ 'dist',
15
+ 'build',
16
+ '.next',
17
+ '.nuxt',
18
+ '.turbo',
19
+ 'coverage',
20
+ '.cache',
21
+ '.DS_Store',
22
+ 'bun.lockb',
23
+ '.env',
24
+ '.env.local',
25
+ '.env.production',
26
+ '.env.development',
27
+ ];
28
+
29
+ function shouldExclude(name: string): boolean {
30
+ for (const pattern of EXCLUDED_PATTERNS) {
31
+ if (pattern.includes('*')) {
32
+ const regex = new RegExp(`^${pattern.replace(/\*/g, '.*')}$`);
33
+ if (regex.test(name)) return true;
34
+ } else if (name === pattern || name.endsWith(pattern)) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ async function traverseDirectory(
42
+ dir: string,
43
+ projectRoot: string,
44
+ maxDepth: number,
45
+ currentDepth = 0,
46
+ limit: number,
47
+ collected: string[] = [],
48
+ ): Promise<{ files: string[]; truncated: boolean }> {
49
+ if (currentDepth >= maxDepth || collected.length >= limit) {
50
+ return { files: collected, truncated: collected.length >= limit };
51
+ }
52
+
53
+ try {
54
+ const entries = await readdir(dir, { withFileTypes: true });
55
+
56
+ for (const entry of entries) {
57
+ if (collected.length >= limit) {
58
+ return { files: collected, truncated: true };
59
+ }
60
+
61
+ if (shouldExclude(entry.name)) {
62
+ continue;
63
+ }
64
+
65
+ const fullPath = join(dir, entry.name);
66
+ const relativePath = relative(projectRoot, fullPath);
67
+
68
+ if (entry.isDirectory()) {
69
+ const result = await traverseDirectory(
70
+ fullPath,
71
+ projectRoot,
72
+ maxDepth,
73
+ currentDepth + 1,
74
+ limit,
75
+ collected,
76
+ );
77
+ if (result.truncated) {
78
+ return result;
79
+ }
80
+ } else if (entry.isFile()) {
81
+ collected.push(relativePath);
82
+ }
83
+ }
84
+ } catch (err) {
85
+ logger.warn(`Failed to read directory ${dir}:`, err);
86
+ }
87
+
88
+ return { files: collected, truncated: false };
89
+ }
90
+
91
+ async function getChangedFiles(
92
+ projectRoot: string,
93
+ ): Promise<Map<string, string>> {
94
+ try {
95
+ const { stdout } = await execAsync('git status --porcelain', {
96
+ cwd: projectRoot,
97
+ });
98
+ const changedFiles = new Map<string, string>();
99
+ for (const line of stdout.split('\n')) {
100
+ if (line.length > 3) {
101
+ const statusCode = line.substring(0, 2).trim();
102
+ const filePath = line.substring(3).trim();
103
+
104
+ let status = 'modified';
105
+ if (statusCode.includes('A')) status = 'added';
106
+ else if (statusCode.includes('M')) status = 'modified';
107
+ else if (statusCode.includes('D')) status = 'deleted';
108
+ else if (statusCode.includes('R')) status = 'renamed';
109
+ else if (statusCode.includes('?')) status = 'untracked';
110
+
111
+ changedFiles.set(filePath, status);
112
+ }
113
+ }
114
+ return changedFiles;
115
+ } catch (err) {
116
+ return new Set();
117
+ }
118
+ }
119
+
120
+ export function registerFilesRoutes(app: Hono) {
121
+ app.get('/v1/files', async (c) => {
122
+ try {
123
+ const projectRoot = c.req.query('project') || process.cwd();
124
+ const maxDepth = Number.parseInt(c.req.query('maxDepth') || '10', 10);
125
+ const limit = Number.parseInt(c.req.query('limit') || '1000', 10);
126
+
127
+ const result = await traverseDirectory(
128
+ projectRoot,
129
+ projectRoot,
130
+ maxDepth,
131
+ 0,
132
+ limit,
133
+ );
134
+
135
+ const changedFiles = await getChangedFiles(projectRoot);
136
+
137
+ result.files.sort((a, b) => {
138
+ const aChanged = changedFiles.has(a);
139
+ const bChanged = changedFiles.has(b);
140
+ if (aChanged && !bChanged) return -1;
141
+ if (!aChanged && bChanged) return 1;
142
+ return a.localeCompare(b);
143
+ });
144
+
145
+ return c.json({
146
+ files: result.files,
147
+ changedFiles: Array.from(changedFiles.entries()).map(
148
+ ([path, status]) => ({
149
+ path,
150
+ status,
151
+ }),
152
+ ),
153
+ truncated: result.truncated,
154
+ });
155
+ } catch (err) {
156
+ logger.error('Files route error:', err);
157
+ return c.json({ error: serializeError(err) }, 500);
158
+ }
159
+ });
160
+ }
@@ -109,6 +109,9 @@ export async function buildHistoryMessages(
109
109
  }
110
110
 
111
111
  for (const call of toolCalls) {
112
+ // Skip finish tool from history - it's internal loop control
113
+ if (call.name === 'finish') continue;
114
+
112
115
  const toolType = `tool-${call.name}` as `tool-${string}`;
113
116
  const result = toolResults.find((r) => r.callId === call.callId);
114
117
 
@@ -76,33 +76,6 @@ async function processQueue(sessionId: string) {
76
76
  cleanupSession(sessionId);
77
77
  }
78
78
 
79
- /**
80
- * Ensures the finish tool is called if not already observed.
81
- */
82
- async function ensureFinishToolCalled(
83
- finishObserved: boolean,
84
- toolset: ReturnType<typeof adaptTools>,
85
- sharedCtx: RunnerToolContext,
86
- stepIndex: number,
87
- ) {
88
- if (finishObserved || !toolset?.finish?.execute) return;
89
-
90
- const finishInput = {} as const;
91
- const callOptions = { input: finishInput } as const;
92
-
93
- sharedCtx.stepIndex = stepIndex;
94
-
95
- try {
96
- await toolset.finish.onInputStart?.(callOptions as never);
97
- } catch {}
98
-
99
- try {
100
- await toolset.finish.onInputAvailable?.(callOptions as never);
101
- } catch {}
102
-
103
- await toolset.finish.execute(finishInput, {} as never);
104
- }
105
-
106
79
  /**
107
80
  * Main function to run the assistant for a given request.
108
81
  */
@@ -290,12 +263,7 @@ async function runAssistant(opts: RunOpts) {
290
263
 
291
264
  const onAbort = createAbortHandler(opts, db, getStepIndex, sharedCtx);
292
265
 
293
- const onFinish = createFinishHandler(
294
- opts,
295
- db,
296
- () => ensureFinishToolCalled(finishObserved, toolset, sharedCtx, stepIndex),
297
- completeAssistantMessage,
298
- );
266
+ const onFinish = createFinishHandler(opts, db, completeAssistantMessage);
299
267
 
300
268
  // Apply optimizations: deduplication, pruning, cache control, and truncation
301
269
  const { addCacheControl, truncateHistory } = await import(
@@ -264,7 +264,6 @@ export function createAbortHandler(
264
264
  export function createFinishHandler(
265
265
  opts: RunOpts,
266
266
  db: Awaited<ReturnType<typeof getDb>>,
267
- ensureFinishToolCalled: () => Promise<void>,
268
267
  completeAssistantMessageFn: (
269
268
  fin: FinishEvent,
270
269
  opts: RunOpts,
@@ -272,10 +271,6 @@ export function createFinishHandler(
272
271
  ) => Promise<void>,
273
272
  ) {
274
273
  return async (fin: FinishEvent) => {
275
- try {
276
- await ensureFinishToolCalled();
277
- } catch {}
278
-
279
274
  // Note: Token updates are handled incrementally in onStepFinish
280
275
  // Do NOT add fin.usage here as it would cause double-counting
281
276
 
@@ -5,6 +5,7 @@ import { publish } from '../events/bus.ts';
5
5
  import type { DiscoveredTool } from '@agi-cli/sdk';
6
6
  import { getCwd, setCwd, joinRelative } from '../runtime/cwd.ts';
7
7
  import type { ToolAdapterContext } from '../runtime/tool-context.ts';
8
+ import { isToolError } from '@agi-cli/sdk/tools/error';
8
9
 
9
10
  export type { ToolAdapterContext } from '../runtime/tool-context.ts';
10
11
 
@@ -403,7 +404,8 @@ export function adaptTools(
403
404
  }
404
405
  return result;
405
406
  } catch (error) {
406
- // Tool execution failed - save error to database as tool_result
407
+ // Tool execution failed
408
+ // Check if the error is already a structured tool error response
407
409
  const resultPartId = crypto.randomUUID();
408
410
  const callId = callIdFromQueue;
409
411
  const startTs = startTsFromQueue;
@@ -411,15 +413,21 @@ export function adaptTools(
411
413
  const dur =
412
414
  typeof startTs === 'number' ? Math.max(0, endTs - startTs) : null;
413
415
 
414
- const errorMessage =
415
- error instanceof Error ? error.message : String(error);
416
- const errorStack = error instanceof Error ? error.stack : undefined;
417
-
418
- const errorResult = {
419
- ok: false,
420
- error: errorMessage,
421
- stack: errorStack,
422
- };
416
+ // If the tool returned a structured error response, use it directly
417
+ // Otherwise, wrap the thrown error in our standard format
418
+ let errorResult: unknown;
419
+ if (isToolError(error)) {
420
+ errorResult = error;
421
+ } else {
422
+ const errorMessage =
423
+ error instanceof Error ? error.message : String(error);
424
+ const errorStack = error instanceof Error ? error.stack : undefined;
425
+ errorResult = {
426
+ ok: false,
427
+ error: errorMessage,
428
+ stack: errorStack,
429
+ };
430
+ }
423
431
 
424
432
  const contentObj = {
425
433
  name,