nine-hundred 0.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.
Files changed (107) hide show
  1. package/README.md +43 -0
  2. package/dist/agent/config/index.js +85 -0
  3. package/dist/agent/context/compress.js +145 -0
  4. package/dist/agent/context/index.js +68 -0
  5. package/dist/agent/context/token.js +30 -0
  6. package/dist/agent/hooks/config.js +41 -0
  7. package/dist/agent/hooks/core.js +177 -0
  8. package/dist/agent/hooks/events/index.js +9 -0
  9. package/dist/agent/hooks/events/post-tool-use-hook.js +7 -0
  10. package/dist/agent/hooks/events/pre-tool-use-hook.js +8 -0
  11. package/dist/agent/hooks/events/session-start-hook.js +8 -0
  12. package/dist/agent/hooks/events/stop-hook.js +10 -0
  13. package/dist/agent/hooks/events/user-prompt-submit-hook.js +7 -0
  14. package/dist/agent/hooks/index.js +7 -0
  15. package/dist/agent/hooks/types.js +32 -0
  16. package/dist/agent/index.js +465 -0
  17. package/dist/agent/llm/index.js +35 -0
  18. package/dist/agent/mcp/client.js +87 -0
  19. package/dist/agent/mcp/config.js +51 -0
  20. package/dist/agent/mcp/index.js +90 -0
  21. package/dist/agent/mcp/schema.js +56 -0
  22. package/dist/agent/permission/exec.js +71 -0
  23. package/dist/agent/permission/network.js +37 -0
  24. package/dist/agent/permission/read.js +27 -0
  25. package/dist/agent/permission/util/command-changes-directory.js +29 -0
  26. package/dist/agent/permission/util/dangerous-path.json +116 -0
  27. package/dist/agent/permission/util/detect-dangerous-operation.js +230 -0
  28. package/dist/agent/permission/util/detect-language-interpreter.js +66 -0
  29. package/dist/agent/permission/util/detect-safe-command.js +61 -0
  30. package/dist/agent/permission/util/index.js +16 -0
  31. package/dist/agent/permission/util/is-dangerous-path.js +98 -0
  32. package/dist/agent/permission/util/is-inside-cwd.js +83 -0
  33. package/dist/agent/permission/util/is-safe-domains.js +47 -0
  34. package/dist/agent/permission/write.js +31 -0
  35. package/dist/agent/prompt/index.js +42 -0
  36. package/dist/agent/skills/planner/SKILL.md +90 -0
  37. package/dist/agent/skills/programmer-resume/SKILL.md +113 -0
  38. package/dist/agent/skills/skill-creator/LICENSE.txt +202 -0
  39. package/dist/agent/skills/skill-creator/SKILL.md +495 -0
  40. package/dist/agent/skills/skill-creator/agents/analyzer.md +274 -0
  41. package/dist/agent/skills/skill-creator/agents/comparator.md +202 -0
  42. package/dist/agent/skills/skill-creator/agents/grader.md +223 -0
  43. package/dist/agent/skills/skill-creator/assets/eval_review.html +146 -0
  44. package/dist/agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
  45. package/dist/agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
  46. package/dist/agent/skills/skill-creator/references/schemas.md +430 -0
  47. package/dist/agent/skills/skill-creator/scripts/__init__.py +0 -0
  48. package/dist/agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
  49. package/dist/agent/skills/skill-creator/scripts/generate_report.py +326 -0
  50. package/dist/agent/skills/skill-creator/scripts/improve_description.py +247 -0
  51. package/dist/agent/skills/skill-creator/scripts/package_skill.py +136 -0
  52. package/dist/agent/skills/skill-creator/scripts/quick_validate.py +103 -0
  53. package/dist/agent/skills/skill-creator/scripts/run_eval.py +310 -0
  54. package/dist/agent/skills/skill-creator/scripts/run_loop.py +328 -0
  55. package/dist/agent/skills/skill-creator/scripts/utils.py +47 -0
  56. package/dist/agent/skills.js +129 -0
  57. package/dist/agent/tools/agent_tool/agent_tool.test.js +64 -0
  58. package/dist/agent/tools/agent_tool/index.js +33 -0
  59. package/dist/agent/tools/exec_tool/exec_tool.test.js +48 -0
  60. package/dist/agent/tools/exec_tool/index.js +44 -0
  61. package/dist/agent/tools/load_skill_tool/index.js +5 -0
  62. package/dist/agent/tools/load_skill_tool/load_skill_tool.test.js +122 -0
  63. package/dist/agent/tools/memory_create_tool/index.js +8 -0
  64. package/dist/agent/tools/memory_create_tool/memory_create_tool.test.js +54 -0
  65. package/dist/agent/tools/memory_delete_tool/index.js +10 -0
  66. package/dist/agent/tools/memory_delete_tool/memory_delete_tool.test.js +39 -0
  67. package/dist/agent/tools/memory_retrieve_tool/index.js +61 -0
  68. package/dist/agent/tools/memory_retrieve_tool/memory_retrieve_tool.test.js +102 -0
  69. package/dist/agent/tools/profile_update_tool/index.js +30 -0
  70. package/dist/agent/tools/profile_update_tool/profile_update_tool.test.js +49 -0
  71. package/dist/agent/tools/read_file_tool/index.js +24 -0
  72. package/dist/agent/tools/read_file_tool/read_file_tool.test.js +43 -0
  73. package/dist/agent/tools/run_js_tool/index.js +48 -0
  74. package/dist/agent/tools/run_js_tool/run_js_tool.test.js +67 -0
  75. package/dist/agent/tools/run_py_tool/index.js +48 -0
  76. package/dist/agent/tools/run_py_tool/run_py_tool.test.js +67 -0
  77. package/dist/agent/tools/tool_logger.js +16 -0
  78. package/dist/agent/tools/tool_logger.test.js +22 -0
  79. package/dist/agent/tools/web_fetch_url/index.js +76 -0
  80. package/dist/agent/tools/web_fetch_url/web_fetch_url.test.js +102 -0
  81. package/dist/agent/tools/web_search_tool/index.js +26 -0
  82. package/dist/agent/tools/web_search_tool/web_search_tool.test.js +61 -0
  83. package/dist/agent/tools/write_file_tool/index.js +22 -0
  84. package/dist/agent/tools/write_file_tool/write_file_tool.test.js +46 -0
  85. package/dist/agent/tools.js +218 -0
  86. package/dist/cli/command/compact/index.js +14 -0
  87. package/dist/cli/command/index.js +62 -0
  88. package/dist/cli/command/invalid/index.js +4 -0
  89. package/dist/cli/command/new/chat-session.js +10 -0
  90. package/dist/cli/command/new/index.js +8 -0
  91. package/dist/cli/command/rewind/index.js +19 -0
  92. package/dist/cli/command/rewind/rewind-command.test.js +22 -0
  93. package/dist/cli/command/session/format.js +32 -0
  94. package/dist/cli/command/session/index.js +32 -0
  95. package/dist/cli/command/session/session-command.test.js +49 -0
  96. package/dist/cli/command/unknown/index.js +4 -0
  97. package/dist/cli/index.js +144 -0
  98. package/dist/db/checkpointer.js +15 -0
  99. package/dist/db/index.js +2 -0
  100. package/dist/db/path.js +8 -0
  101. package/dist/db/sessions.js +81 -0
  102. package/dist/db/tables/memory.js +12 -0
  103. package/dist/db/tables/memory_fts.js +29 -0
  104. package/dist/index.js +87 -0
  105. package/dist/install.js +154 -0
  106. package/package.json +51 -0
  107. package/pnpm-workspace.yaml +3 -0
@@ -0,0 +1,67 @@
1
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { runJsTool } from './index.js';
6
+ let originalCwd;
7
+ let tempRoot;
8
+ let currentDir;
9
+ let outsideDir;
10
+ beforeEach(async () => {
11
+ originalCwd = process.cwd();
12
+ tempRoot = await mkdtemp(path.join(tmpdir(), 'argos-run-js-'));
13
+ currentDir = path.join(tempRoot, 'current');
14
+ outsideDir = path.join(tempRoot, 'outside');
15
+ await mkdir(currentDir);
16
+ await mkdir(outsideDir);
17
+ process.chdir(currentDir);
18
+ });
19
+ afterEach(async () => {
20
+ process.chdir(originalCwd);
21
+ await rm(tempRoot, { recursive: true, force: true });
22
+ });
23
+ describe('runJsTool', () => {
24
+ it('executes JavaScript and returns stdout', async () => {
25
+ const result = await runJsTool('console.log(1 + 2)');
26
+ expect(result).toContain('Exit code: 0');
27
+ expect(result).toContain('stdout:');
28
+ expect(result).toContain('3');
29
+ });
30
+ it('executes multiline JavaScript', async () => {
31
+ const result = await runJsTool(`
32
+ const values = [1, 2, 3];
33
+ console.log(values.map((value) => value * value).join(','));
34
+ `);
35
+ expect(result).toContain('1,4,9');
36
+ });
37
+ it('returns stdout and stderr', async () => {
38
+ const result = await runJsTool("console.error('warn'); console.log('ok');");
39
+ expect(result).toContain('ok');
40
+ expect(result).toContain('warn');
41
+ });
42
+ it('returns JavaScript errors to the caller', async () => {
43
+ const result = await runJsTool("throw new Error('boom');");
44
+ expect(result).toContain('JavaScript execution failed with exit code');
45
+ expect(result).toContain('Error: boom');
46
+ });
47
+ it('executes JavaScript in a nested working directory', async () => {
48
+ await mkdir('src');
49
+ await writeFile(path.join('src', 'data.txt'), 'nested data');
50
+ const result = await runJsTool("const fs = require('node:fs'); console.log(fs.readFileSync('data.txt', 'utf8'));", 'src');
51
+ expect(result).toContain('nested data');
52
+ });
53
+ it('rejects path traversal outside the current working directory', async () => {
54
+ await expect(runJsTool('console.log(1)', '..')).rejects.toThrow('Can only execute JavaScript inside the current working directory.');
55
+ });
56
+ it('rejects absolute working directories outside the current working directory', async () => {
57
+ await expect(runJsTool('console.log(1)', outsideDir)).rejects.toThrow('Can only execute JavaScript inside the current working directory.');
58
+ });
59
+ it('rejects symlink working directories that point outside the current working directory', async () => {
60
+ await symlink(outsideDir, 'outside-link');
61
+ await expect(runJsTool('console.log(1)', 'outside-link')).rejects.toThrow('Can only execute JavaScript inside the current working directory.');
62
+ });
63
+ it('rejects empty JavaScript code', async () => {
64
+ await expect(runJsTool('')).rejects.toThrow('JavaScript code is required.');
65
+ await expect(runJsTool(' ')).rejects.toThrow('JavaScript code is required.');
66
+ });
67
+ });
@@ -0,0 +1,48 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ const OUTSIDE_CWD_ERROR = 'Can only execute Python inside the current working directory.';
7
+ const PYTHON_NOT_FOUND_MESSAGE = 'Python 3 is required to run Python code, but the "python3" executable was not found. Please install Python 3 and try again.';
8
+ async function resolveWorkingDirectory(cwd) {
9
+ const root = await realpath(process.cwd());
10
+ const requestedCwd = path.resolve(root, cwd);
11
+ const targetCwd = await realpath(requestedCwd);
12
+ const relativePath = path.relative(root, targetCwd);
13
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
14
+ throw new Error(OUTSIDE_CWD_ERROR);
15
+ }
16
+ const stats = await stat(targetCwd);
17
+ if (!stats.isDirectory()) {
18
+ throw new Error('Working directory must be a directory.');
19
+ }
20
+ return targetCwd;
21
+ }
22
+ function formatSuccess(stdout, stderr) {
23
+ return `Exit code: 0\n\nstdout:\n${stdout}\n\nstderr:\n${stderr}`;
24
+ }
25
+ function formatFailure(code, stdout, stderr) {
26
+ return `Python execution failed with exit code ${code}.\n\nstdout:\n${stdout}\n\nstderr:\n${stderr}`;
27
+ }
28
+ export async function runPyTool(code, cwd = '.') {
29
+ if (!code.trim()) {
30
+ throw new Error('Python code is required.');
31
+ }
32
+ const targetCwd = await resolveWorkingDirectory(cwd);
33
+ try {
34
+ const { stdout, stderr } = await execFileAsync('python3', ['-c', code], {
35
+ cwd: targetCwd,
36
+ timeout: 10_000,
37
+ maxBuffer: 1024 * 1024,
38
+ });
39
+ return formatSuccess(stdout, stderr);
40
+ }
41
+ catch (error) {
42
+ const execError = error;
43
+ if (execError.code === 'ENOENT') {
44
+ return PYTHON_NOT_FOUND_MESSAGE;
45
+ }
46
+ return formatFailure(execError.code ?? 'unknown', execError.stdout ?? '', execError.stderr ?? '');
47
+ }
48
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { runPyTool } from './index.js';
6
+ let originalCwd;
7
+ let tempRoot;
8
+ let currentDir;
9
+ let outsideDir;
10
+ beforeEach(async () => {
11
+ originalCwd = process.cwd();
12
+ tempRoot = await mkdtemp(path.join(tmpdir(), 'argos-run-py-'));
13
+ currentDir = path.join(tempRoot, 'current');
14
+ outsideDir = path.join(tempRoot, 'outside');
15
+ await mkdir(currentDir);
16
+ await mkdir(outsideDir);
17
+ process.chdir(currentDir);
18
+ });
19
+ afterEach(async () => {
20
+ process.chdir(originalCwd);
21
+ await rm(tempRoot, { recursive: true, force: true });
22
+ });
23
+ describe('runPyTool', () => {
24
+ it('executes Python and returns stdout', async () => {
25
+ const result = await runPyTool('print(1 + 2)');
26
+ expect(result).toContain('Exit code: 0');
27
+ expect(result).toContain('stdout:');
28
+ expect(result).toContain('3');
29
+ });
30
+ it('executes multiline Python', async () => {
31
+ const result = await runPyTool(`
32
+ values = [1, 2, 3]
33
+ print(','.join(str(value * value) for value in values))
34
+ `);
35
+ expect(result).toContain('1,4,9');
36
+ });
37
+ it('returns stdout and stderr', async () => {
38
+ const result = await runPyTool("import sys; print('warn', file=sys.stderr); print('ok')");
39
+ expect(result).toContain('ok');
40
+ expect(result).toContain('warn');
41
+ });
42
+ it('returns Python errors to the caller', async () => {
43
+ const result = await runPyTool("raise Exception('boom')");
44
+ expect(result).toContain('Python execution failed with exit code');
45
+ expect(result).toContain('Exception: boom');
46
+ });
47
+ it('executes Python in a nested working directory', async () => {
48
+ await mkdir('src');
49
+ await writeFile(path.join('src', 'data.txt'), 'nested data');
50
+ const result = await runPyTool("from pathlib import Path\nprint(Path('data.txt').read_text())", 'src');
51
+ expect(result).toContain('nested data');
52
+ });
53
+ it('rejects path traversal outside the current working directory', async () => {
54
+ await expect(runPyTool('print(1)', '..')).rejects.toThrow('Can only execute Python inside the current working directory.');
55
+ });
56
+ it('rejects absolute working directories outside the current working directory', async () => {
57
+ await expect(runPyTool('print(1)', outsideDir)).rejects.toThrow('Can only execute Python inside the current working directory.');
58
+ });
59
+ it('rejects symlink working directories that point outside the current working directory', async () => {
60
+ await symlink(outsideDir, 'outside-link');
61
+ await expect(runPyTool('print(1)', 'outside-link')).rejects.toThrow('Can only execute Python inside the current working directory.');
62
+ });
63
+ it('rejects empty Python code', async () => {
64
+ await expect(runPyTool('')).rejects.toThrow('Python code is required.');
65
+ await expect(runPyTool(' ')).rejects.toThrow('Python code is required.');
66
+ });
67
+ });
@@ -0,0 +1,16 @@
1
+ import chalk from 'chalk';
2
+ const MAX_SUMMARY_LENGTH = 120;
3
+ export function logToolCall(toolName, summary) {
4
+ const safeSummary = summary ? ` ${chalk.yellow(truncateSummary(summary))}` : '';
5
+ console.log(`\n${chalk.dim.cyan('[Tool]')} ${chalk.cyan(toolName)} ${chalk.dim('called:')}${safeSummary}`);
6
+ }
7
+ export function formatToolArg(name, value) {
8
+ if (!value)
9
+ return undefined;
10
+ return `${name}=${JSON.stringify(truncateSummary(value))}`;
11
+ }
12
+ function truncateSummary(summary) {
13
+ if (summary.length <= MAX_SUMMARY_LENGTH)
14
+ return summary;
15
+ return `${summary.slice(0, MAX_SUMMARY_LENGTH - 1)}…`;
16
+ }
@@ -0,0 +1,22 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { formatToolArg, logToolCall } from './tool_logger.js';
4
+ describe('tool logger', () => {
5
+ it('prints a colored tool call log with a plain text equivalent', () => {
6
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
7
+ logToolCall('exec_tool', 'command="ls"');
8
+ expect(stripVTControlCharacters(logSpy.mock.calls[0]?.[0] ?? '').trim()).toBe('[Tool] exec_tool called: command="ls"');
9
+ });
10
+ it('formats string arguments as JSON values', () => {
11
+ expect(formatToolArg('filePath', 'src/index.ts')).toBe('filePath="src/index.ts"');
12
+ });
13
+ it('omits empty optional arguments', () => {
14
+ expect(formatToolArg('cwd', undefined)).toBeUndefined();
15
+ expect(formatToolArg('cwd', '')).toBeUndefined();
16
+ });
17
+ it('truncates long argument values', () => {
18
+ const value = 'a'.repeat(200);
19
+ expect(formatToolArg('command', value)).toHaveLength(130);
20
+ expect(formatToolArg('command', value)).toContain('…"');
21
+ });
22
+ });
@@ -0,0 +1,76 @@
1
+ const MAX_RESPONSE_CHARS = 20_000;
2
+ const FETCH_TIMEOUT_MS = 10_000;
3
+ const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:']);
4
+ function parseHttpUrl(url) {
5
+ let parsedUrl;
6
+ try {
7
+ parsedUrl = new URL(url);
8
+ }
9
+ catch {
10
+ throw new Error('Invalid URL.');
11
+ }
12
+ if (!SUPPORTED_PROTOCOLS.has(parsedUrl.protocol)) {
13
+ throw new Error('Only HTTP and HTTPS URLs are supported.');
14
+ }
15
+ return parsedUrl;
16
+ }
17
+ function isTextContent(contentType) {
18
+ const normalizedContentType = contentType.toLowerCase();
19
+ return (!normalizedContentType ||
20
+ normalizedContentType.startsWith('text/') ||
21
+ normalizedContentType.includes('application/json') ||
22
+ normalizedContentType.includes('application/xml') ||
23
+ normalizedContentType.includes('application/xhtml+xml') ||
24
+ normalizedContentType.includes('application/javascript'));
25
+ }
26
+ function truncateContent(content) {
27
+ if (content.length <= MAX_RESPONSE_CHARS) {
28
+ return content;
29
+ }
30
+ return `${content.slice(0, MAX_RESPONSE_CHARS)}\n\n[Content truncated to ${MAX_RESPONSE_CHARS} characters.]`;
31
+ }
32
+ function formatFetchError(error) {
33
+ if (error instanceof Error && error.message) {
34
+ return `Web fetch failed: ${error.message}`;
35
+ }
36
+ return 'Web fetch failed.';
37
+ }
38
+ function formatStatus(response) {
39
+ return response.statusText ? `${response.status} ${response.statusText}` : String(response.status);
40
+ }
41
+ function formatResponse(url, response, contentType, content) {
42
+ return `URL: ${url.toString()}\nStatus: ${formatStatus(response)}\nContent-Type: ${contentType || 'unknown'}\n\n${content}`;
43
+ }
44
+ export async function webFetchUrl(url) {
45
+ const trimmedUrl = url.trim();
46
+ if (!trimmedUrl) {
47
+ throw new Error('URL is required.');
48
+ }
49
+ const parsedUrl = parseHttpUrl(trimmedUrl);
50
+ const abortController = new AbortController();
51
+ const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
52
+ try {
53
+ const response = await fetch(parsedUrl.toString(), {
54
+ headers: {
55
+ Accept: 'text/html, application/json, text/plain, application/xml, */*',
56
+ },
57
+ signal: abortController.signal,
58
+ });
59
+ const contentType = response.headers.get('content-type') ?? '';
60
+ if (!isTextContent(contentType)) {
61
+ const contentLength = response.headers.get('content-length') ?? 'unknown';
62
+ return `URL: ${parsedUrl.toString()}\nStatus: ${formatStatus(response)}\nContent-Type: ${contentType}\n\nFetched non-text resource (${contentLength} bytes).`;
63
+ }
64
+ const content = truncateContent(await response.text());
65
+ if (!response.ok) {
66
+ return `Web fetch failed: HTTP ${formatStatus(response)}\n\n${formatResponse(parsedUrl, response, contentType, content)}`;
67
+ }
68
+ return formatResponse(parsedUrl, response, contentType, content);
69
+ }
70
+ catch (error) {
71
+ return formatFetchError(error);
72
+ }
73
+ finally {
74
+ clearTimeout(timeout);
75
+ }
76
+ }
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { webFetchUrl } from './index.js';
3
+ const fetchMock = vi.fn();
4
+ function createHeaders(headers) {
5
+ return new Headers(headers);
6
+ }
7
+ function createResponse({ body, headers = { 'content-type': 'text/html; charset=utf-8' }, ok = true, status = 200, statusText = 'OK', }) {
8
+ return {
9
+ headers: createHeaders(headers),
10
+ ok,
11
+ status,
12
+ statusText,
13
+ text: vi.fn().mockResolvedValue(body),
14
+ };
15
+ }
16
+ beforeEach(() => {
17
+ fetchMock.mockReset();
18
+ vi.stubGlobal('fetch', fetchMock);
19
+ });
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ });
23
+ describe('webFetchUrl', () => {
24
+ it('fetches text content from a URL', async () => {
25
+ fetchMock.mockResolvedValue(createResponse({ body: '<html>Hello</html>' }));
26
+ const result = await webFetchUrl('https://example.com/');
27
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/', expect.objectContaining({
28
+ headers: expect.objectContaining({
29
+ Accept: 'text/html, application/json, text/plain, application/xml, */*',
30
+ }),
31
+ }));
32
+ expect(result).toContain('URL: https://example.com/');
33
+ expect(result).toContain('Status: 200 OK');
34
+ expect(result).toContain('Content-Type: text/html; charset=utf-8');
35
+ expect(result).toContain('<html>Hello</html>');
36
+ });
37
+ it('fetches JSON content from a URL', async () => {
38
+ fetchMock.mockResolvedValue(createResponse({
39
+ body: '{"ok":true}',
40
+ headers: { 'content-type': 'application/json' },
41
+ }));
42
+ const result = await webFetchUrl('https://api.example.com/data');
43
+ expect(result).toContain('Content-Type: application/json');
44
+ expect(result).toContain('{"ok":true}');
45
+ });
46
+ it('trims the URL before fetching', async () => {
47
+ fetchMock.mockResolvedValue(createResponse({ body: 'ok' }));
48
+ await webFetchUrl(' https://example.com/path ');
49
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/path', expect.any(Object));
50
+ });
51
+ it('rejects empty URLs', async () => {
52
+ await expect(webFetchUrl('')).rejects.toThrow('URL is required.');
53
+ await expect(webFetchUrl(' ')).rejects.toThrow('URL is required.');
54
+ });
55
+ it('rejects invalid URLs', async () => {
56
+ await expect(webFetchUrl('not-a-url')).rejects.toThrow('Invalid URL.');
57
+ });
58
+ it('rejects non-HTTP URLs', async () => {
59
+ await expect(webFetchUrl('file:///etc/passwd')).rejects.toThrow('Only HTTP and HTTPS URLs are supported.');
60
+ await expect(webFetchUrl('ftp://example.com/file')).rejects.toThrow('Only HTTP and HTTPS URLs are supported.');
61
+ });
62
+ it('returns a readable failure for non-OK HTTP responses', async () => {
63
+ fetchMock.mockResolvedValue(createResponse({
64
+ body: 'missing',
65
+ ok: false,
66
+ status: 404,
67
+ statusText: 'Not Found',
68
+ }));
69
+ const result = await webFetchUrl('https://example.com/missing');
70
+ expect(result).toContain('Web fetch failed: HTTP 404 Not Found');
71
+ expect(result).toContain('missing');
72
+ });
73
+ it('returns a readable failure when fetch throws an Error', async () => {
74
+ fetchMock.mockRejectedValue(new Error('getaddrinfo ENOTFOUND example.test'));
75
+ await expect(webFetchUrl('https://example.test/')).resolves.toBe('Web fetch failed: getaddrinfo ENOTFOUND example.test');
76
+ });
77
+ it('returns a generic failure when fetch throws a non-error value', async () => {
78
+ fetchMock.mockRejectedValue('boom');
79
+ await expect(webFetchUrl('https://example.com/')).resolves.toBe('Web fetch failed.');
80
+ });
81
+ it('truncates long text responses', async () => {
82
+ fetchMock.mockResolvedValue(createResponse({ body: 'a'.repeat(20_001) }));
83
+ const result = await webFetchUrl('https://example.com/long');
84
+ expect(result).toContain('a'.repeat(20_000));
85
+ expect(result).toContain('[Content truncated to 20000 characters.]');
86
+ });
87
+ it('returns metadata for non-text resources', async () => {
88
+ const response = createResponse({
89
+ body: 'binary',
90
+ headers: {
91
+ 'content-length': '1024',
92
+ 'content-type': 'image/png',
93
+ },
94
+ });
95
+ const textMock = vi.mocked(response.text);
96
+ fetchMock.mockResolvedValue(response);
97
+ const result = await webFetchUrl('https://example.com/image.png');
98
+ expect(textMock).not.toHaveBeenCalled();
99
+ expect(result).toContain('Content-Type: image/png');
100
+ expect(result).toContain('Fetched non-text resource (1024 bytes).');
101
+ });
102
+ });
@@ -0,0 +1,26 @@
1
+ import { TavilySearch } from '@langchain/tavily';
2
+ import { getEnvValue } from '../../config/index.js';
3
+ function formatSearchError(error) {
4
+ if (error instanceof Error && error.message) {
5
+ return `Web search failed: ${error.message}`;
6
+ }
7
+ return 'Web search failed.';
8
+ }
9
+ export async function webSearchTool(query) {
10
+ const trimmedQuery = query.trim();
11
+ if (!trimmedQuery) {
12
+ throw new Error('Search query is required.');
13
+ }
14
+ try {
15
+ const tavilySearch = new TavilySearch({
16
+ maxResults: 3,
17
+ topic: 'general',
18
+ tavilyApiKey: getEnvValue('TAVILY_API_KEY'),
19
+ });
20
+ const result = await tavilySearch.invoke({ query: trimmedQuery });
21
+ return typeof result === 'string' ? result : JSON.stringify(result);
22
+ }
23
+ catch (error) {
24
+ return formatSearchError(error);
25
+ }
26
+ }
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { webSearchTool } from './index.js';
3
+ const { invokeMock, tavilySearchMock } = vi.hoisted(() => {
4
+ const invokeMock = vi.fn();
5
+ const tavilySearchMock = vi.fn(function () {
6
+ return {
7
+ invoke: invokeMock,
8
+ };
9
+ });
10
+ return { invokeMock, tavilySearchMock };
11
+ });
12
+ vi.mock('@langchain/tavily', () => ({
13
+ TavilySearch: tavilySearchMock,
14
+ }));
15
+ vi.mock('../../config/index.js', () => ({
16
+ getEnvValue: vi.fn(() => undefined),
17
+ }));
18
+ beforeEach(() => {
19
+ invokeMock.mockReset();
20
+ tavilySearchMock.mockClear();
21
+ });
22
+ describe('webSearchTool', () => {
23
+ it('searches the web with Tavily', async () => {
24
+ invokeMock.mockResolvedValue('mocked result');
25
+ await expect(webSearchTool('LangChain Tavily')).resolves.toBe('mocked result');
26
+ expect(tavilySearchMock).toHaveBeenCalledWith({
27
+ maxResults: 3,
28
+ topic: 'general',
29
+ });
30
+ expect(invokeMock).toHaveBeenCalledWith({ query: 'LangChain Tavily' });
31
+ });
32
+ it('trims the search query before invoking Tavily', async () => {
33
+ invokeMock.mockResolvedValue('trimmed result');
34
+ await expect(webSearchTool(' latest AI news ')).resolves.toBe('trimmed result');
35
+ expect(invokeMock).toHaveBeenCalledWith({ query: 'latest AI news' });
36
+ });
37
+ it('rejects empty search queries', async () => {
38
+ await expect(webSearchTool('')).rejects.toThrow('Search query is required.');
39
+ await expect(webSearchTool(' ')).rejects.toThrow('Search query is required.');
40
+ });
41
+ it('serializes non-string Tavily results', async () => {
42
+ const result = {
43
+ results: [
44
+ {
45
+ title: 'Example',
46
+ url: 'https://example.com',
47
+ },
48
+ ],
49
+ };
50
+ invokeMock.mockResolvedValue(result);
51
+ await expect(webSearchTool('example')).resolves.toBe(JSON.stringify(result));
52
+ });
53
+ it('returns a readable error when Tavily search fails', async () => {
54
+ invokeMock.mockRejectedValue(new Error('Invalid API key'));
55
+ await expect(webSearchTool('example')).resolves.toBe('Web search failed: Invalid API key');
56
+ });
57
+ it('returns a generic error when Tavily throws a non-error value', async () => {
58
+ invokeMock.mockRejectedValue('boom');
59
+ await expect(webSearchTool('example')).resolves.toBe('Web search failed.');
60
+ });
61
+ });
@@ -0,0 +1,22 @@
1
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export async function writeFileTool(filePath, content) {
4
+ if (!filePath.trim()) {
5
+ throw new Error('File path is required.');
6
+ }
7
+ const target = path.resolve(filePath);
8
+ try {
9
+ const stats = await stat(target);
10
+ if (!stats.isFile()) {
11
+ throw new Error('Can only write regular files.');
12
+ }
13
+ }
14
+ catch (error) {
15
+ if (error.code !== 'ENOENT') {
16
+ throw error;
17
+ }
18
+ }
19
+ await mkdir(path.dirname(target), { recursive: true });
20
+ await writeFile(target, content, 'utf8');
21
+ return `Wrote file: ${target}`;
22
+ }
@@ -0,0 +1,46 @@
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { writeFileTool } from './index.js';
6
+ let tempRoot;
7
+ beforeEach(async () => {
8
+ tempRoot = await mkdtemp(path.join(tmpdir(), 'argos-write-file-'));
9
+ });
10
+ afterEach(async () => {
11
+ await rm(tempRoot, { recursive: true, force: true });
12
+ });
13
+ describe('writeFileTool', () => {
14
+ it('writes a file by absolute path', async () => {
15
+ const filePath = path.join(tempRoot, 'hello.txt');
16
+ await expect(writeFileTool(filePath, 'hello argos')).resolves.toBe(`Wrote file: ${filePath}`);
17
+ await expect(readFile(filePath, 'utf8')).resolves.toBe('hello argos');
18
+ });
19
+ it('writes a nested file', async () => {
20
+ const filePath = path.join(tempRoot, 'nested', 'hello.txt');
21
+ await expect(writeFileTool(filePath, 'nested hello')).resolves.toBe(`Wrote file: ${filePath}`);
22
+ await expect(readFile(filePath, 'utf8')).resolves.toBe('nested hello');
23
+ });
24
+ it('overwrites an existing regular file', async () => {
25
+ const filePath = path.join(tempRoot, 'hello.txt');
26
+ await writeFile(filePath, 'old content');
27
+ await expect(writeFileTool(filePath, 'new content')).resolves.toBe(`Wrote file: ${filePath}`);
28
+ await expect(readFile(filePath, 'utf8')).resolves.toBe('new content');
29
+ });
30
+ it('writes a file by relative path', async () => {
31
+ const result = await writeFileTool('hello-relative.txt', 'hello relative');
32
+ expect(result).toMatch(/Wrote file: .*hello-relative\.txt$/);
33
+ const resolvedPath = path.resolve('hello-relative.txt');
34
+ await expect(readFile(resolvedPath, 'utf8')).resolves.toBe('hello relative');
35
+ await rm(resolvedPath);
36
+ });
37
+ it('rejects writing to directories', async () => {
38
+ const dirPath = path.join(tempRoot, 'nested');
39
+ await mkdir(dirPath);
40
+ await expect(writeFileTool(dirPath, 'content')).rejects.toThrow('Can only write regular files.');
41
+ });
42
+ it('rejects empty paths', async () => {
43
+ await expect(writeFileTool('', 'content')).rejects.toThrow('File path is required.');
44
+ await expect(writeFileTool(' ', 'content')).rejects.toThrow('File path is required.');
45
+ });
46
+ });