@agi-cli/sdk 0.1.54 → 0.1.56

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.
Files changed (81) hide show
  1. package/package.json +53 -27
  2. package/src/agent/types.ts +1 -1
  3. package/src/auth/src/index.ts +70 -0
  4. package/src/auth/src/oauth.ts +172 -0
  5. package/src/config/src/index.ts +120 -0
  6. package/src/config/src/manager.ts +102 -0
  7. package/src/config/src/paths.ts +98 -0
  8. package/src/core/src/errors.ts +102 -0
  9. package/src/core/src/index.ts +75 -0
  10. package/src/core/src/providers/resolver.ts +84 -0
  11. package/src/core/src/streaming/artifacts.ts +41 -0
  12. package/src/core/src/tools/builtin/bash.ts +90 -0
  13. package/src/core/src/tools/builtin/bash.txt +7 -0
  14. package/src/core/src/tools/builtin/edit.ts +152 -0
  15. package/src/core/src/tools/builtin/edit.txt +7 -0
  16. package/src/core/src/tools/builtin/file-cache.ts +39 -0
  17. package/src/core/src/tools/builtin/finish.ts +11 -0
  18. package/src/core/src/tools/builtin/finish.txt +5 -0
  19. package/src/core/src/tools/builtin/fs/cd.ts +19 -0
  20. package/src/core/src/tools/builtin/fs/cd.txt +5 -0
  21. package/src/core/src/tools/builtin/fs/index.ts +20 -0
  22. package/src/core/src/tools/builtin/fs/ls.ts +60 -0
  23. package/src/core/src/tools/builtin/fs/ls.txt +8 -0
  24. package/src/core/src/tools/builtin/fs/pwd.ts +17 -0
  25. package/src/core/src/tools/builtin/fs/pwd.txt +5 -0
  26. package/src/core/src/tools/builtin/fs/read.ts +80 -0
  27. package/src/core/src/tools/builtin/fs/read.txt +8 -0
  28. package/src/core/src/tools/builtin/fs/tree.ts +71 -0
  29. package/src/core/src/tools/builtin/fs/tree.txt +8 -0
  30. package/src/core/src/tools/builtin/fs/util.ts +95 -0
  31. package/src/core/src/tools/builtin/fs/write.ts +61 -0
  32. package/src/core/src/tools/builtin/fs/write.txt +8 -0
  33. package/src/core/src/tools/builtin/git.commit.txt +6 -0
  34. package/src/core/src/tools/builtin/git.diff.txt +5 -0
  35. package/src/core/src/tools/builtin/git.status.txt +5 -0
  36. package/src/core/src/tools/builtin/git.ts +128 -0
  37. package/src/core/src/tools/builtin/grep.ts +140 -0
  38. package/src/core/src/tools/builtin/grep.txt +9 -0
  39. package/src/core/src/tools/builtin/ignore.ts +45 -0
  40. package/src/core/src/tools/builtin/patch.ts +269 -0
  41. package/src/core/src/tools/builtin/patch.txt +7 -0
  42. package/src/core/src/tools/builtin/plan.ts +58 -0
  43. package/src/core/src/tools/builtin/plan.txt +6 -0
  44. package/src/core/src/tools/builtin/progress.ts +55 -0
  45. package/src/core/src/tools/builtin/progress.txt +7 -0
  46. package/src/core/src/tools/builtin/ripgrep.ts +102 -0
  47. package/src/core/src/tools/builtin/ripgrep.txt +7 -0
  48. package/src/core/src/tools/builtin/websearch.ts +219 -0
  49. package/src/core/src/tools/builtin/websearch.txt +12 -0
  50. package/src/core/src/tools/loader.ts +398 -0
  51. package/src/core/src/types/index.ts +11 -0
  52. package/src/core/src/types/types.ts +4 -0
  53. package/src/index.ts +57 -58
  54. package/src/prompts/src/agents/build.txt +5 -0
  55. package/src/prompts/src/agents/general.txt +6 -0
  56. package/src/prompts/src/agents/plan.txt +13 -0
  57. package/src/prompts/src/base.txt +14 -0
  58. package/src/prompts/src/debug.ts +104 -0
  59. package/src/prompts/src/index.ts +1 -0
  60. package/src/prompts/src/modes/oneshot.txt +9 -0
  61. package/src/prompts/src/providers/anthropic.txt +151 -0
  62. package/src/prompts/src/providers/anthropicSpoof.txt +1 -0
  63. package/src/prompts/src/providers/default.txt +310 -0
  64. package/src/prompts/src/providers/google.txt +155 -0
  65. package/src/prompts/src/providers/openai.txt +310 -0
  66. package/src/prompts/src/providers.ts +116 -0
  67. package/src/providers/src/authorization.ts +17 -0
  68. package/src/providers/src/catalog.ts +4201 -0
  69. package/src/providers/src/env.ts +26 -0
  70. package/src/providers/src/index.ts +12 -0
  71. package/src/providers/src/pricing.ts +135 -0
  72. package/src/providers/src/utils.ts +24 -0
  73. package/src/providers/src/validate.ts +39 -0
  74. package/src/types/src/auth.ts +26 -0
  75. package/src/types/src/config.ts +40 -0
  76. package/src/types/src/index.ts +14 -0
  77. package/src/types/src/provider.ts +28 -0
  78. package/src/global.d.ts +0 -4
  79. package/src/tools/builtin/fs.ts +0 -1
  80. package/src/tools/builtin/git.ts +0 -1
  81. package/src/web-ui.ts +0 -8
@@ -0,0 +1,140 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { exec } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { join } from 'node:path';
6
+ import { stat } from 'node:fs/promises';
7
+ import DESCRIPTION from './grep.txt' with { type: 'text' };
8
+ import { defaultIgnoreGlobs } from './ignore.ts';
9
+
10
+ const execAsync = promisify(exec);
11
+
12
+ function expandTilde(p: string) {
13
+ const home = process.env.HOME || process.env.USERPROFILE || '';
14
+ if (!home) return p;
15
+ if (p === '~') return home;
16
+ if (p.startsWith('~/')) return `${home}/${p.slice(2)}`;
17
+ return p;
18
+ }
19
+
20
+ export function buildGrepTool(projectRoot: string): {
21
+ name: string;
22
+ tool: Tool;
23
+ } {
24
+ const grep = tool({
25
+ description: DESCRIPTION,
26
+ inputSchema: z.object({
27
+ pattern: z
28
+ .string()
29
+ .describe('Regex pattern to search for in file contents'),
30
+ path: z
31
+ .string()
32
+ .optional()
33
+ .describe('Directory to search in (default: project root).'),
34
+ include: z
35
+ .string()
36
+ .optional()
37
+ .describe('File glob to include (e.g., "*.js", "*.{ts,tsx}")'),
38
+ ignore: z
39
+ .array(z.string())
40
+ .optional()
41
+ .describe('Glob patterns to exclude from search'),
42
+ }),
43
+ async execute(params) {
44
+ const pattern = String(params.pattern || '');
45
+ if (!pattern) throw new Error('pattern is required');
46
+
47
+ const p = expandTilde(String(params.path || '')).trim();
48
+ const isAbs = p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p);
49
+ const searchPath = p ? (isAbs ? p : join(projectRoot, p)) : projectRoot;
50
+
51
+ let cmd = `rg -n --color never`;
52
+ for (const g of defaultIgnoreGlobs(params.ignore)) {
53
+ cmd += ` --glob "${g.replace(/"/g, '\\"')}"`;
54
+ }
55
+ if (params.include) {
56
+ cmd += ` --glob "${params.include.replace(/"/g, '\\"')}"`;
57
+ }
58
+ cmd += ` "${pattern.replace(/"/g, '\\"')}" "${searchPath}"`;
59
+
60
+ let output = '';
61
+ try {
62
+ const result = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 });
63
+ output = result.stdout;
64
+ } catch (error: unknown) {
65
+ const err = error as { code?: number; stderr?: string };
66
+ if (err.code === 1) {
67
+ return {
68
+ title: pattern,
69
+ metadata: { matches: 0, truncated: false },
70
+ output: 'No files found',
71
+ };
72
+ }
73
+ const err2 = error as { stderr?: string; message?: string };
74
+ throw new Error(`ripgrep failed: ${err2.stderr || err2.message}`);
75
+ }
76
+
77
+ const lines = output.trim().split('\n');
78
+ const matches: Array<{
79
+ path: string;
80
+ modTime: number;
81
+ lineNum: number;
82
+ lineText: string;
83
+ }> = [];
84
+
85
+ for (const line of lines) {
86
+ if (!line) continue;
87
+ const idx1 = line.indexOf(':');
88
+ const idx2 = idx1 === -1 ? -1 : line.indexOf(':', idx1 + 1);
89
+ if (idx1 === -1 || idx2 === -1) continue;
90
+ const filePath = line.slice(0, idx1);
91
+ const lineNumStr = line.slice(idx1 + 1, idx2);
92
+ const lineText = line.slice(idx2 + 1);
93
+ const lineNum = parseInt(lineNumStr, 10);
94
+ if (!filePath || !Number.isFinite(lineNum)) continue;
95
+ const stats = await stat(filePath)
96
+ .then((s) => s.mtime.getTime())
97
+ .catch(() => 0);
98
+ matches.push({ path: filePath, modTime: stats, lineNum, lineText });
99
+ }
100
+
101
+ matches.sort((a, b) => b.modTime - a.modTime);
102
+
103
+ const limit = 100;
104
+ const truncated = matches.length > limit;
105
+ const finalMatches = truncated ? matches.slice(0, limit) : matches;
106
+
107
+ if (finalMatches.length === 0) {
108
+ return {
109
+ title: pattern,
110
+ metadata: { matches: 0, truncated: false },
111
+ output: 'No files found',
112
+ };
113
+ }
114
+
115
+ const outputLines = [`Found ${finalMatches.length} matches`];
116
+ let currentFile = '';
117
+ for (const match of finalMatches) {
118
+ if (currentFile !== match.path) {
119
+ if (currentFile !== '') outputLines.push('');
120
+ currentFile = match.path;
121
+ outputLines.push(`${match.path}:`);
122
+ }
123
+ outputLines.push(` Line ${match.lineNum}: ${match.lineText}`);
124
+ }
125
+ if (truncated) {
126
+ outputLines.push('');
127
+ outputLines.push(
128
+ '(Results are truncated. Consider using a more specific path or pattern.)',
129
+ );
130
+ }
131
+
132
+ return {
133
+ title: pattern,
134
+ metadata: { matches: finalMatches.length, truncated },
135
+ output: outputLines.join('\n'),
136
+ };
137
+ },
138
+ });
139
+ return { name: 'grep', tool: grep };
140
+ }
@@ -0,0 +1,9 @@
1
+ - Fast content search tool powered by ripgrep (rg)
2
+ - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
3
+ - Optional include glob to filter files (e.g., "*.js", "*.{ts,tsx}")
4
+ - Returns files with at least one match and line previews, sorted by modification time
5
+ - Skips common build and cache folders by default; add 'ignore' patterns to refine
6
+
7
+ Usage tips:
8
+ - For counting matches, use the Bash tool with rg directly (do not use grep)
9
+ - Batch multiple searches when exploring a codebase broadly
@@ -0,0 +1,45 @@
1
+ export const IGNORE_PATTERNS: string[] = [
2
+ 'node_modules/',
3
+ '__pycache__/',
4
+ '.git/',
5
+ 'dist/',
6
+ 'build/',
7
+ 'target/',
8
+ 'vendor/',
9
+ 'bin/',
10
+ 'obj/',
11
+ '.idea/',
12
+ '.vscode/',
13
+ '.zig-cache/',
14
+ 'zig-out',
15
+ '.coverage',
16
+ 'coverage/',
17
+ 'vendor/',
18
+ 'tmp/',
19
+ 'temp/',
20
+ '.cache/',
21
+ 'cache/',
22
+ 'logs/',
23
+ '.venv/',
24
+ 'venv/',
25
+ 'env/',
26
+ ];
27
+
28
+ export function defaultIgnoreGlobs(extra?: string[]): string[] {
29
+ const base = IGNORE_PATTERNS.map((p) => `!${p}*`);
30
+ if (Array.isArray(extra) && extra.length) return base.concat(extra);
31
+ return base;
32
+ }
33
+
34
+ export function toIgnoredBasenames(extra?: string[]): Set<string> {
35
+ const names = new Set<string>();
36
+ for (const p of IGNORE_PATTERNS) {
37
+ const n = p.replace(/\/$/, '');
38
+ if (n) names.add(n);
39
+ }
40
+ for (const p of extra ?? []) {
41
+ const n = String(p).replace(/^!/, '').replace(/\/$/, '');
42
+ if (n) names.add(n);
43
+ }
44
+ return names;
45
+ }
@@ -0,0 +1,269 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { exec } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { writeFile, readFile, mkdir } from 'node:fs/promises';
6
+ import DESCRIPTION from './patch.txt' with { type: 'text' };
7
+
8
+ const execAsync = promisify(exec);
9
+
10
+ /**
11
+ * Apply enveloped patch by directly modifying files
12
+ */
13
+ async function applyEnvelopedPatch(projectRoot: string, patch: string) {
14
+ const lines = patch.split('\n');
15
+ let currentFile: string | null = null;
16
+ let operation: 'add' | 'update' | 'delete' | null = null;
17
+ let fileContent: string[] = [];
18
+
19
+ async function applyCurrentFile() {
20
+ if (!currentFile || !operation) return { ok: true };
21
+
22
+ const fullPath = `${projectRoot}/${currentFile}`;
23
+
24
+ if (operation === 'delete') {
25
+ try {
26
+ await writeFile(fullPath, '');
27
+ } catch (e) {
28
+ return {
29
+ ok: false,
30
+ error: `Failed to delete ${currentFile}: ${e instanceof Error ? e.message : String(e)}`,
31
+ };
32
+ }
33
+ } else if (operation === 'add') {
34
+ // For add, only use lines starting with +
35
+ const newContent = fileContent
36
+ .filter((l) => l.startsWith('+'))
37
+ .map((l) => l.substring(1))
38
+ .join('\n');
39
+ try {
40
+ await writeFile(fullPath, newContent);
41
+ } catch (e) {
42
+ return {
43
+ ok: false,
44
+ error: `Failed to create ${currentFile}: ${e instanceof Error ? e.message : String(e)}`,
45
+ };
46
+ }
47
+ } else if (operation === 'update') {
48
+ try {
49
+ // Read existing file
50
+ let existingContent = '';
51
+ try {
52
+ existingContent = await readFile(fullPath, 'utf-8');
53
+ } catch {
54
+ // File doesn't exist yet
55
+ }
56
+
57
+ // Get the old content (lines starting with -)
58
+ const oldLines = fileContent
59
+ .filter((l) => l.startsWith('-'))
60
+ .map((l) => l.substring(1));
61
+
62
+ // Get the new content (lines starting with +)
63
+ const newLines = fileContent
64
+ .filter((l) => l.startsWith('+'))
65
+ .map((l) => l.substring(1));
66
+
67
+ // Simple replacement: if old content is empty, append
68
+ // Otherwise try to replace old with new
69
+ let newContent = existingContent;
70
+ if (oldLines.length > 0) {
71
+ const oldText = oldLines.join('\n');
72
+ const newText = newLines.join('\n');
73
+ if (existingContent.includes(oldText)) {
74
+ newContent = existingContent.replace(oldText, newText);
75
+ } else {
76
+ // Can't find exact match, this is where enveloped format fails
77
+ // Provide more context about what couldn't be found
78
+ const preview = oldText.substring(0, 100);
79
+ return {
80
+ ok: false,
81
+ error: `Cannot find content to replace in ${currentFile}. Looking for: "${preview}${oldText.length > 100 ? '...' : ''}"`,
82
+ };
83
+ }
84
+ } else if (newLines.length > 0) {
85
+ // Just appending new lines
86
+ newContent =
87
+ existingContent +
88
+ (existingContent.endsWith('\n') ? '' : '\n') +
89
+ newLines.join('\n');
90
+ }
91
+
92
+ await writeFile(fullPath, newContent);
93
+ } catch (e) {
94
+ return {
95
+ ok: false,
96
+ error: `Failed to update ${currentFile}: ${e instanceof Error ? e.message : String(e)}`,
97
+ };
98
+ }
99
+ }
100
+ return { ok: true };
101
+ }
102
+
103
+ for (const line of lines) {
104
+ if (line === '*** Begin Patch' || line === '*** End Patch') {
105
+ continue;
106
+ }
107
+
108
+ if (
109
+ line.startsWith('*** Add File:') ||
110
+ line.startsWith('*** Update File:') ||
111
+ line.startsWith('*** Delete File:')
112
+ ) {
113
+ // Apply previous file if any
114
+ const result = await applyCurrentFile();
115
+ if (!result.ok) return result;
116
+
117
+ // Start new file
118
+ if (line.startsWith('*** Add File:')) {
119
+ currentFile = line.replace('*** Add File:', '').trim();
120
+ operation = 'add';
121
+ } else if (line.startsWith('*** Update File:')) {
122
+ currentFile = line.replace('*** Update File:', '').trim();
123
+ operation = 'update';
124
+ } else if (line.startsWith('*** Delete File:')) {
125
+ currentFile = line.replace('*** Delete File:', '').trim();
126
+ operation = 'delete';
127
+ }
128
+ fileContent = [];
129
+ } else if (
130
+ currentFile &&
131
+ (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))
132
+ ) {
133
+ // Collect patch content lines
134
+ fileContent.push(line);
135
+ }
136
+ }
137
+
138
+ // Apply the last file
139
+ const result = await applyCurrentFile();
140
+ return result;
141
+ }
142
+
143
+ export function buildApplyPatchTool(projectRoot: string): {
144
+ name: string;
145
+ tool: Tool;
146
+ } {
147
+ const applyPatch = tool({
148
+ description: DESCRIPTION,
149
+ inputSchema: z.object({
150
+ patch: z.string().min(1).describe('Unified diff patch content'),
151
+ allowRejects: z
152
+ .boolean()
153
+ .optional()
154
+ .default(false)
155
+ .describe(
156
+ 'Allow hunks to be rejected without failing the whole operation',
157
+ ),
158
+ }),
159
+ async execute({
160
+ patch,
161
+ allowRejects,
162
+ }: {
163
+ patch: string;
164
+ allowRejects?: boolean;
165
+ }) {
166
+ // Check if this is an enveloped patch format
167
+ const isEnveloped =
168
+ patch.includes('*** Begin Patch') ||
169
+ patch.includes('*** Add File:') ||
170
+ patch.includes('*** Update File:');
171
+
172
+ if (isEnveloped) {
173
+ // Handle enveloped patches directly
174
+ const result = await applyEnvelopedPatch(projectRoot, patch);
175
+ const summary = summarizePatch(patch);
176
+ if (result.ok) {
177
+ return {
178
+ ok: true,
179
+ output: 'Applied enveloped patch',
180
+ artifact: { kind: 'file_diff', patch, summary },
181
+ } as const;
182
+ } else {
183
+ return {
184
+ ok: false,
185
+ error: result.error || 'Failed to apply enveloped patch',
186
+ artifact: { kind: 'file_diff', patch, summary },
187
+ } as const;
188
+ }
189
+ }
190
+
191
+ // For unified diffs, use git apply as before
192
+ const dir = `${projectRoot}/.agi/tmp`;
193
+ await mkdir(dir, { recursive: true }).catch(() => {});
194
+ const file = `${dir}/patch-${Date.now()}.diff`;
195
+ await writeFile(file, patch);
196
+ const summary = summarizePatch(patch);
197
+ // Try -p1 first for canonical git-style patches (a/ b/ prefixes), then fall back to -p0.
198
+ const baseArgs = ['apply', '--whitespace=nowarn'];
199
+ const rejectArg = allowRejects ? '--reject' : '';
200
+ const tries = [
201
+ `git -C "${projectRoot}" ${baseArgs.join(' ')} ${rejectArg} -p1 "${file}"`,
202
+ `git -C "${projectRoot}" ${baseArgs.join(' ')} ${rejectArg} -p0 "${file}"`,
203
+ ];
204
+ let lastError = '';
205
+ for (const cmd of tries) {
206
+ try {
207
+ const { stdout } = await execAsync(cmd, {
208
+ maxBuffer: 10 * 1024 * 1024,
209
+ });
210
+ // Check if any files were actually modified
211
+ try {
212
+ const { stdout: statusOut } = await execAsync(
213
+ `git -C "${projectRoot}" status --porcelain`,
214
+ );
215
+ if (statusOut && statusOut.trim().length > 0) {
216
+ return {
217
+ ok: true,
218
+ output: stdout.trim(),
219
+ artifact: { kind: 'file_diff', patch, summary },
220
+ } as const;
221
+ }
222
+ } catch {}
223
+ } catch (error: unknown) {
224
+ const err = error as { stderr?: string; message?: string };
225
+ lastError = err.stderr || err.message || 'git apply failed';
226
+ }
227
+ }
228
+
229
+ // Final check if files were modified anyway
230
+ try {
231
+ const { stdout: statusOut } = await execAsync(
232
+ `git -C "${projectRoot}" status --porcelain`,
233
+ );
234
+ if (statusOut && statusOut.trim().length > 0) {
235
+ return {
236
+ ok: true,
237
+ output: 'Patch applied with warnings',
238
+ artifact: { kind: 'file_diff', patch, summary },
239
+ } as const;
240
+ }
241
+ } catch {}
242
+
243
+ // If both attempts fail and no files changed, return error with more context
244
+ const errorDetails = lastError.includes('patch does not apply')
245
+ ? 'The patch cannot be applied because the target content has changed or does not match. The file may have been modified since the patch was created.'
246
+ : lastError ||
247
+ 'git apply failed (tried -p1 and -p0) — ensure paths match project root';
248
+ return {
249
+ ok: false,
250
+ error: errorDetails,
251
+ artifact: { kind: 'file_diff', patch, summary },
252
+ } as const;
253
+ },
254
+ });
255
+ return { name: 'apply_patch', tool: applyPatch };
256
+ }
257
+
258
+ function summarizePatch(patch: string) {
259
+ const lines = String(patch || '').split('\n');
260
+ let files = 0;
261
+ let additions = 0;
262
+ let deletions = 0;
263
+ for (const l of lines) {
264
+ if (/^\*\*\*\s+(Add|Update|Delete) File:/.test(l)) files += 1;
265
+ else if (l.startsWith('+') && !l.startsWith('+++')) additions += 1;
266
+ else if (l.startsWith('-') && !l.startsWith('---')) deletions += 1;
267
+ }
268
+ return { files, additions, deletions };
269
+ }
@@ -0,0 +1,7 @@
1
+ - Apply a unified diff patch (`*** Begin Patch`/`*** Update File`/`---`/`+++`/`@@`)
2
+ - Uses `git apply` under the hood; tries `-p1` then `-p0`
3
+ - Returns an artifact summary and any output from `git apply`
4
+
5
+ Usage tips:
6
+ - Ensure paths in the patch match the project root (prefer `a/` and `b/` prefixes)
7
+ - For small edits, consider the Edit or Write tools instead
@@ -0,0 +1,58 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import DESCRIPTION from './plan.txt' with { type: 'text' };
4
+
5
+ const STATUS_ENUM = z.enum(['pending', 'in_progress', 'completed']);
6
+
7
+ const ITEM_SCHEMA = z
8
+ .union([
9
+ z.string().min(1, 'Plan steps must be non-empty'),
10
+ z.object({
11
+ step: z.string().min(1, 'Plan steps must be non-empty'),
12
+ status: STATUS_ENUM.optional(),
13
+ }),
14
+ ])
15
+ .describe('Plan item');
16
+
17
+ type PlanItemInput = z.infer<typeof ITEM_SCHEMA>;
18
+
19
+ function normalizeItems(
20
+ raw: PlanItemInput[],
21
+ ): Array<{ step: string; status: z.infer<typeof STATUS_ENUM> }> {
22
+ const normalized = raw.map((item) => {
23
+ if (typeof item === 'string') {
24
+ return { step: item.trim(), status: 'pending' as const };
25
+ }
26
+ const step = item.step.trim();
27
+ const status = item.status ?? 'pending';
28
+ return { step, status };
29
+ });
30
+
31
+ const filtered = normalized.filter((item) => item.step.length > 0);
32
+ if (!filtered.length) {
33
+ throw new Error('At least one plan step is required');
34
+ }
35
+
36
+ const inProgressCount = filtered.filter(
37
+ (item) => item.status === 'in_progress',
38
+ ).length;
39
+ if (inProgressCount > 1) {
40
+ throw new Error('Only one plan step may be marked as in_progress');
41
+ }
42
+
43
+ return filtered;
44
+ }
45
+
46
+ export const updatePlanTool: Tool = tool({
47
+ description: DESCRIPTION,
48
+ inputSchema: z.object({
49
+ items: z.array(ITEM_SCHEMA).min(1).describe('Ordered list of plan steps'),
50
+ note: z
51
+ .string()
52
+ .optional()
53
+ .describe('Optional note or context for the plan update'),
54
+ }),
55
+ async execute({ items, note }: { items: PlanItemInput[]; note?: string }) {
56
+ return { items: normalizeItems(items), note };
57
+ },
58
+ });
@@ -0,0 +1,6 @@
1
+ - Publish or update the current execution plan
2
+ - Displays ordered steps and optional note/context to the user
3
+
4
+ Usage tips:
5
+ - Keep steps concise (imperative verbs) and reflect progress updates
6
+ - Update the plan whenever scope changes or new steps emerge
@@ -0,0 +1,55 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import DESCRIPTION from './progress.txt' with { type: 'text' };
4
+
5
+ // Progress update tool: allows the model to emit lightweight status signals
6
+ // without revealing chain-of-thought. The runner/UI should surface these
7
+ // messages immediately.
8
+ const StageEnum = z.enum([
9
+ 'planning',
10
+ 'discovering',
11
+ 'generating',
12
+ 'preparing',
13
+ 'writing',
14
+ 'verifying',
15
+ ]);
16
+
17
+ export const progressUpdateTool = tool({
18
+ description: DESCRIPTION,
19
+ inputSchema: z.object({
20
+ message: z
21
+ .string()
22
+ .min(1)
23
+ .max(200)
24
+ .describe('Short, user-facing status message (<= 200 chars).'),
25
+ pct: z
26
+ .number()
27
+ .min(0)
28
+ .max(100)
29
+ .optional()
30
+ .describe('Optional overall progress percent 0-100.'),
31
+ stage: StageEnum.optional().default('planning'),
32
+ }),
33
+ async execute({
34
+ message,
35
+ pct,
36
+ stage,
37
+ }: {
38
+ message: string;
39
+ pct?: number;
40
+ stage?: z.infer<typeof StageEnum>;
41
+ }) {
42
+ // Keep the tool lightweight; no side effects beyond the event itself.
43
+ // Returning the normalized payload allows generic renderers to inspect it if needed.
44
+ const normalizedPct =
45
+ typeof pct === 'number'
46
+ ? Math.min(100, Math.max(0, Math.round(pct)))
47
+ : undefined;
48
+ return {
49
+ ok: true,
50
+ message,
51
+ pct: normalizedPct,
52
+ stage: stage ?? 'planning',
53
+ } as const;
54
+ },
55
+ });
@@ -0,0 +1,7 @@
1
+ - Emit a short, user-facing progress/status update
2
+ - Supports optional `pct` (0–100) and `stage` indicators
3
+ - Lightweight; intended for immediate UI display
4
+
5
+ Usage tips:
6
+ - Keep messages short (<= 200 chars) and informative
7
+ - Use multiple updates during long-running tasks