diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fab70a7..f155d73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - "*" + - '*' pull_request: branches: - main @@ -11,6 +11,10 @@ on: permissions: contents: read +env: + pnpm-version: 10.2.1 + node-version: 23 + jobs: ci: runs-on: ubuntu-latest @@ -19,15 +23,15 @@ jobs: - uses: pnpm/action-setup@v2 with: - version: 10.2.1 + version: ${{ env.pnpm-version }} - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: - node-version: 23 + node-version: ${{ env.node-version }} - name: Install dependencies run: pnpm install --frozen-lockfile - + - name: Build run: pnpm build:ci @@ -38,4 +42,4 @@ jobs: run: pnpm test - name: Lint - run: pnpm lint \ No newline at end of file + run: pnpm lint diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0afdd4c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +.DS_Store +dist +*.local +node_modules/* +dist +coverage +pnpm-lock.yaml \ No newline at end of file diff --git a/BROWSER_AUTOMATION.md b/BROWSER_AUTOMATION.md deleted file mode 100644 index de69287..0000000 --- a/BROWSER_AUTOMATION.md +++ /dev/null @@ -1,173 +0,0 @@ -# Browser Automation - -## Overview - -This document describes the browser automation capabilities implemented in the project, combining both the original planning and the current implementation status. - -## Key Features - -- Browser session management -- Page control and interaction -- Resource cleanup and management -- Error handling -- Type-safe API - -## Architecture - -The browser automation system is implemented as a modular system with the following core components: - -### BrowserManager -Handles browser session lifecycle, including: -- Session creation and cleanup -- Context management -- Resource optimization - -### PageController -Manages page interactions: -- Navigation -- Element selection and interaction -- State management -- Event handling - -## Installation & Setup - -```bash -npm install @mycoder/browser-automation -``` - -### Configuration - -Basic configuration example: -```typescript -import { BrowserAutomation } from '@mycoder/browser-automation'; - -const browser = new BrowserAutomation({ - headless: true, - defaultViewport: { width: 1920, height: 1080 } -}); -``` - -## Usage Examples - -### Basic Navigation -```typescript -const page = await browser.newPage(); -await page.navigate('https://example.com'); -await page.waitForSelector('.main-content'); -``` - -### Interacting with Elements -```typescript -await page.click('#submit-button'); -await page.type('#search-input', 'search term'); -``` - -## Error Handling - -The system implements comprehensive error handling: - -- Browser-specific errors are wrapped in custom error types -- Automatic retry mechanisms for flaky operations -- Resource cleanup on failure -- Detailed error messages and stack traces - -## Resource Management - -Resources are managed automatically: - -- Browser sessions are cleaned up when no longer needed -- Memory usage is optimized -- Concurrent sessions are managed efficiently -- Automatic page context cleanup - -## Testing & Debugging - -### Running Tests -```bash -npm test -``` - -### Debugging - -Debug logs can be enabled via environment variables: -```bash -DEBUG=browser-automation:* npm test -``` - -## API Reference - -### BrowserAutomation -Main class providing browser automation capabilities. - -#### Methods -- `newPage()`: Creates a new page session -- `close()`: Closes all browser sessions -- `evaluate()`: Evaluates JavaScript in the page context - -### PageController -Handles page-specific operations. - -#### Methods -- `navigate(url: string)`: Navigates to URL -- `click(selector: string)`: Clicks element -- `type(selector: string, text: string)`: Types text -- `waitForSelector(selector: string)`: Waits for element - -## Future Enhancements - -The following features are planned for future releases: - -- Network monitoring capabilities -- Video recording support -- Trace viewer integration -- Extended cross-browser support -- Enhanced selector handling module - -## Migration Guide - -When upgrading from previous versions: - -1. Update import paths to use new structure -2. Replace deprecated methods with new alternatives -3. Update configuration objects to match new schema -4. Test thoroughly after migration - -## Best Practices - -1. Always clean up resources: -```typescript -try { - const browser = new BrowserAutomation(); - // ... operations -} finally { - await browser.close(); -} -``` - -2. Use typed selectors: -```typescript -const selector: SelectorOptions = { - type: 'css', - value: '.main-content' -}; -``` - -3. Implement proper error handling: -```typescript -try { - await page.click('#button'); -} catch (error) { - if (error instanceof ElementNotFoundError) { - // Handle missing element - } - throw error; -} -``` - -## Contributing - -Please see CONTRIBUTING.md for guidelines on contributing to this project. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 643b347..bf7b403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # mycoder +## 0.1.1 + +### Patch Changes + +- add respawn tool to help reduce context size + ## 0.1.0 ### Minor Changes diff --git a/COMMIT_CONVENTION.md b/COMMIT_CONVENTION.md index 241b6e2..441891b 100644 --- a/COMMIT_CONVENTION.md +++ b/COMMIT_CONVENTION.md @@ -47,6 +47,7 @@ docs: update README with new API documentation ## Changelog Generation Commit messages are used to: + 1. Automatically determine the next version number 2. Generate changelog entries 3. Create GitHub releases diff --git a/bin/cli.js b/bin/cli.js index 6cf1a59..924fff1 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,2 +1,2 @@ #!/usr/bin/env node -import "../dist/index.js"; +import '../dist/index.js'; diff --git a/eslint.config.js b/eslint.config.js index 526093f..cf9bdf3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,78 +1,78 @@ // eslint.config.js -import js from "@eslint/js"; -import ts from "typescript-eslint"; -import prettierRecommended from "eslint-plugin-prettier/recommended"; -import importPlugin from "eslint-plugin-import"; -import unusedImports from "eslint-plugin-unused-imports"; -import pluginPromise from "eslint-plugin-promise"; +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import prettierRecommended from 'eslint-plugin-prettier/recommended'; +import importPlugin from 'eslint-plugin-import'; +import unusedImports from 'eslint-plugin-unused-imports'; +import pluginPromise from 'eslint-plugin-promise'; export default ts.config( js.configs.recommended, ts.configs.recommendedTypeChecked, prettierRecommended, importPlugin.flatConfigs.recommended, - pluginPromise.configs["flat/recommended"], + pluginPromise.configs['flat/recommended'], { languageOptions: { ecmaVersion: 2022, - sourceType: "module", + sourceType: 'module', parserOptions: { - project: ["./tsconfig.eslint.json"], + project: ['./tsconfig.eslint.json'], tsconfigRootDir: import.meta.dirname, }, }, plugins: { - "unused-imports": unusedImports, + 'unused-imports': unusedImports, }, - files: ["{src,test}/**/*.{js,ts}"], + files: ['{src,test}/**/*.{js,ts}'], rules: { // Basic code quality rules - "no-console": "off", - "prefer-const": "warn", - "no-var": "warn", - eqeqeq: ["warn", "always"], + 'no-console': 'off', + 'prefer-const': 'warn', + 'no-var': 'warn', + eqeqeq: ['warn', 'always'], // Light complexity rules - complexity: ["warn", { max: 20 }], - "max-depth": ["warn", { max: 4 }], - "max-lines-per-function": ["warn", { max: 150 }], + complexity: ['warn', { max: 20 }], + 'max-depth': ['warn', { max: 4 }], + 'max-lines-per-function': ['warn', { max: 150 }], - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], - "import/no-unresolved": "off", - "import/named": "off", - "import/extensions": [ - "error", - "ignorePackages", - { js: "always", ts: "never" }, + 'import/no-unresolved': 'off', + 'import/named': 'off', + 'import/extensions': [ + 'error', + 'ignorePackages', + { js: 'always', ts: 'never' }, ], - "no-unused-vars": "off", // or "@typescript-eslint/no-unused-vars": "off", - "unused-imports/no-unused-imports": "error", + 'no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", + 'unused-imports/no-unused-imports': 'error', - "promise/always-return": "error", - "promise/no-return-wrap": "error", - "promise/param-names": "error", - "promise/catch-or-return": "error", - "promise/no-native": "off", - "promise/no-nesting": "warn", - "promise/no-promise-in-callback": "warn", - "promise/no-callback-in-promise": "warn", - "promise/avoid-new": "off", - "promise/no-new-statics": "error", - "promise/no-return-in-finally": "warn", - "promise/valid-params": "warn", - "promise/no-multiple-resolved": "error", + 'promise/always-return': 'error', + 'promise/no-return-wrap': 'error', + 'promise/param-names': 'error', + 'promise/catch-or-return': 'error', + 'promise/no-native': 'off', + 'promise/no-nesting': 'warn', + 'promise/no-promise-in-callback': 'warn', + 'promise/no-callback-in-promise': 'warn', + 'promise/avoid-new': 'off', + 'promise/no-new-statics': 'error', + 'promise/no-return-in-finally': 'warn', + 'promise/valid-params': 'warn', + 'promise/no-multiple-resolved': 'error', }, - } + }, ); diff --git a/package.json b/package.json index b047d6a..72da325 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mycoder", "description": "A command line tool using agent that can do arbitrary tasks, including coding tasks", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "bin": "./bin/cli.js", "main": "./dist/index.js", @@ -25,7 +25,7 @@ "clean": "rimraf dist", "clean:all": "rimraf dist node_modules", "lint": "eslint \"{src,test}/**/*.{js,ts}\" --fix", - "format": "prettier --write \"src/**/*.*\"", + "format": "prettier --write .", "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest --run --coverage", @@ -54,7 +54,6 @@ "@vitest/browser": "^3.0.5", "chalk": "^5", "dotenv": "^16", - "eslint-plugin-promise": "^7.2.1", "playwright": "^1.50.1", "semver": "^7.7.1", "source-map-support": "^0.5", @@ -76,6 +75,7 @@ "eslint-config-prettier": "^9", "eslint-plugin-import": "^2", "eslint-plugin-prettier": "^5", + "eslint-plugin-promise": "^7.2.1", "eslint-plugin-unused-imports": "^4", "prettier": "^3", "rimraf": "^5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f940665..21db5e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: dotenv: specifier: ^16 version: 16.4.7 - eslint-plugin-promise: - specifier: ^7.2.1 - version: 7.2.1(eslint@9.20.1) playwright: specifier: ^1.50.1 version: 1.50.1 @@ -84,6 +81,9 @@ importers: eslint-plugin-prettier: specifier: ^5 version: 5.2.3(eslint-config-prettier@9.1.0(eslint@9.20.1))(eslint@9.20.1)(prettier@3.5.0) + eslint-plugin-promise: + specifier: ^7.2.1 + version: 7.2.1(eslint@9.20.1) eslint-plugin-unused-imports: specifier: ^4 version: 4.1.4(@typescript-eslint/eslint-plugin@8.24.0(@typescript-eslint/parser@8.24.0(eslint@9.20.1)(typescript@5.7.3))(eslint@9.20.1)(typescript@5.7.3))(eslint@9.20.1) diff --git a/prettier.config.ts b/prettier.config.ts index c14ed9b..6501f13 100644 --- a/prettier.config.ts +++ b/prettier.config.ts @@ -1,12 +1,12 @@ // prettier.config.ts, .prettierrc.ts, prettier.config.mts, or .prettierrc.mts -import { type Config } from "prettier"; +import { type Config } from 'prettier'; const config: Config = { - trailingComma: "all", + trailingComma: 'all', tabWidth: 2, semi: true, singleQuote: true, }; -export default config; \ No newline at end of file +export default config; diff --git a/src/core/toolAgent.ts b/src/core/toolAgent.ts index 7125035..fb751e6 100644 --- a/src/core/toolAgent.ts +++ b/src/core/toolAgent.ts @@ -115,13 +115,31 @@ async function executeTools( tools: Tool[], messages: Message[], logger: Logger, -): Promise { +): Promise { if (toolCalls.length === 0) { return { sequenceCompleted: false, toolResults: [] }; } logger.verbose(`Executing ${toolCalls.length} tool calls`); + // Check for respawn tool call + const respawnCall = toolCalls.find((call) => call.name === 'respawn'); + if (respawnCall) { + return { + sequenceCompleted: false, + toolResults: [ + { + type: 'tool_result', + tool_use_id: respawnCall.id, + content: 'Respawn initiated', + }, + ], + respawn: { + context: respawnCall.input.respawnContext, + }, + }; + } + const results = await Promise.all( toolCalls.map(async (call) => { let toolResult = ''; @@ -242,13 +260,24 @@ export const toolAgent = async ( logger.info(assistantMessage); } - const { sequenceCompleted, completionResult } = await executeTools( + const { sequenceCompleted, completionResult, respawn } = await executeTools( toolCalls, tools, messages, logger, ); + if (respawn) { + logger.info('Respawning agent with new context'); + // Reset messages to just the new context + messages.length = 0; + messages.push({ + role: 'user', + content: [{ type: 'text', text: respawn.context }], + }); + continue; + } + if (sequenceCompleted) { const result = { result: diff --git a/src/tools/getTools.ts b/src/tools/getTools.ts index 3057ebe..b7ca811 100644 --- a/src/tools/getTools.ts +++ b/src/tools/getTools.ts @@ -9,6 +9,7 @@ import { shellStartTool } from './system/shellStart.js'; import { shellMessageTool } from './system/shellMessage.js'; import { browseMessageTool } from './browser/browseMessage.js'; import { browseStartTool } from './browser/browseStart.js'; +import { respawnTool } from './system/respawn.js'; export function getTools(): Tool[] { return [ @@ -22,5 +23,6 @@ export function getTools(): Tool[] { shellMessageTool, browseStartTool, browseMessageTool, + respawnTool, ] as Tool[]; } diff --git a/src/tools/io/updateFile.ts b/src/tools/io/updateFile.ts index 3329819..2684160 100644 --- a/src/tools/io/updateFile.ts +++ b/src/tools/io/updateFile.ts @@ -1,4 +1,5 @@ -import * as fs from 'fs/promises'; +import * as fsPromises from 'fs/promises'; +import * as fs from 'fs'; import * as path from 'path'; import { Tool } from '../../core/types.js'; import { z } from 'zod'; @@ -47,32 +48,35 @@ export const updateFileTool: Tool = { const absolutePath = path.resolve(path.normalize(filePath)); logger.verbose(`Updating file: ${absolutePath}`); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fsPromises.mkdir(path.dirname(absolutePath), { recursive: true }); if (operation.command === 'update') { - const content = await fs.readFile(absolutePath, 'utf8'); + const content = await fsPromises.readFile(absolutePath, 'utf8'); const occurrences = content.split(operation.oldStr).length - 1; if (occurrences !== 1) { throw new Error( `Found ${occurrences} occurrences of oldStr, expected exactly 1`, ); } - await fs.writeFile( + await fsPromises.writeFile( absolutePath, content.replace(operation.oldStr, operation.newStr), 'utf8', ); } else if (operation.command === 'append') { - await fs.appendFile(absolutePath, operation.content, 'utf8'); + await fsPromises.appendFile(absolutePath, operation.content, 'utf8'); } else { - await fs.writeFile(absolutePath, operation.content, 'utf8'); + await fsPromises.writeFile(absolutePath, operation.content, 'utf8'); } logger.verbose(`Operation complete: ${operation.command}`); return { path: filePath, operation: operation.command }; }, logParameters: (input, { logger }) => { - logger.info(`Modifying "${input.path}", ${input.description}`); + const isFile = fs.existsSync(input.path); + logger.info( + `${isFile ? 'Modifying' : 'Creating'} "${input.path}", ${input.description}`, + ); }, logReturns: () => {}, }; diff --git a/src/tools/system/respawn.ts b/src/tools/system/respawn.ts new file mode 100644 index 0000000..1c82f7b --- /dev/null +++ b/src/tools/system/respawn.ts @@ -0,0 +1,33 @@ +import { Tool, ToolContext } from '../../core/types.js'; + +export interface RespawnInput { + respawnContext: string; +} + +export const respawnTool: Tool = { + name: 'respawn', + description: + 'Resets the agent context to just the system prompt and provided context', + parameters: { + type: 'object', + properties: { + respawnContext: { + type: 'string', + description: 'The context to keep after respawning', + }, + }, + required: ['respawnContext'], + additionalProperties: false, + }, + returns: { + type: 'string', + description: 'A message indicating that the respawn has been initiated', + }, + execute: async ( + params: Record, + context: ToolContext, + ): Promise => { + // This is a special case tool - the actual respawn logic is handled in toolAgent + return 'Respawn initiated'; + }, +}; diff --git a/src/tools/system/shellMessage.ts b/src/tools/system/shellMessage.ts index 64ac397..3a1cbb9 100644 --- a/src/tools/system/shellMessage.ts +++ b/src/tools/system/shellMessage.ts @@ -162,8 +162,9 @@ export const shellMessageTool: Tool = { }, logParameters: (input, { logger }) => { + const processState = processStates.get(input.instanceId); logger.info( - `Interacting with process ${input.instanceId}, ${input.description}`, + `Interacting with shell command "${processState ? processState.command : ''}", ${input.description}`, ); }, logReturns: () => {}, diff --git a/src/tools/system/shellStart.ts b/src/tools/system/shellStart.ts index 6512c73..34739cf 100644 --- a/src/tools/system/shellStart.ts +++ b/src/tools/system/shellStart.ts @@ -9,6 +9,7 @@ import { errorToString } from '../../utils/errorToString.js'; // Define ProcessState type type ProcessState = { process: ChildProcess; + command: string; stdout: string[]; stderr: string[]; state: { @@ -86,6 +87,7 @@ export const shellStartTool: Tool = { const process = spawn(command, [], { shell: true }); const processState: ProcessState = { + command, process, stdout: [], stderr: [], diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 9026c5f..279156b 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,18 +1,18 @@ -import { expect, test, describe } from "vitest"; -import { execSync } from "child_process"; -import { version } from "../package.json"; +import { expect, test, describe } from 'vitest'; +import { execSync } from 'child_process'; +import { version } from '../package.json'; -describe("CLI", () => { - test("version command outputs correct version", () => { - const output = execSync("npx mycoder --version").toString(); +describe('CLI', () => { + test('version command outputs correct version', () => { + const output = execSync('npx mycoder --version').toString(); expect(output.trim()).toContain(version); - expect(output.trim()).not.toContain("AI-powered coding assistant"); + expect(output.trim()).not.toContain('AI-powered coding assistant'); }); - test("-h command outputs help", () => { - const output = execSync("npx mycoder -h").toString(); - expect(output.trim()).toContain("Commands:"); - expect(output.trim()).toContain("Positionals:"); - expect(output.trim()).toContain("Options:"); + test('-h command outputs help', () => { + const output = execSync('npx mycoder -h').toString(); + expect(output.trim()).toContain('Commands:'); + expect(output.trim()).toContain('Positionals:'); + expect(output.trim()).toContain('Options:'); }); }); diff --git a/tests/core/toolAgent.respawn.test.ts b/tests/core/toolAgent.respawn.test.ts new file mode 100644 index 0000000..b6b3019 --- /dev/null +++ b/tests/core/toolAgent.respawn.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { toolAgent } from '../../src/core/toolAgent.js'; +import { getTools } from '../../src/tools/getTools.js'; +import { Logger } from '../../src/utils/logger.js'; + +// Mock Anthropic SDK +vi.mock('@anthropic-ai/sdk', () => { + return { + default: vi.fn().mockImplementation(() => ({ + messages: { + create: vi + .fn() + .mockResolvedValueOnce({ + content: [ + { + type: 'tool_use', + name: 'respawn', + id: 'test-id', + input: { respawnContext: 'new context' }, + }, + ], + usage: { input_tokens: 10, output_tokens: 10 }, + }) + .mockResolvedValueOnce({ + content: [], + usage: { input_tokens: 5, output_tokens: 5 }, + }), + }, + })), + }; +}); + +describe('toolAgent respawn functionality', () => { + const mockLogger = new Logger({ name: 'test' }); + const tools = getTools(); + + beforeEach(() => { + process.env.ANTHROPIC_API_KEY = 'test-key'; + vi.clearAllMocks(); + }); + + it('should handle respawn tool calls', async () => { + const result = await toolAgent('initial prompt', tools, mockLogger, { + maxIterations: 2, // Need at least 2 iterations for respawn + empty response + model: 'test-model', + maxTokens: 100, + temperature: 0, + getSystemPrompt: () => 'test system prompt', + }); + + expect(result.result).toBe( + 'Agent returned empty message implying it is done its given task', + ); + }); +}); diff --git a/tests/tools/respawn.test.ts b/tests/tools/respawn.test.ts new file mode 100644 index 0000000..9c74683 --- /dev/null +++ b/tests/tools/respawn.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest'; +import { respawnTool } from '../../src/tools/system/respawn.js'; +import { Logger } from '../../src/utils/logger.js'; + +describe('respawnTool', () => { + const mockLogger = new Logger({ name: 'test' }); + + it('should have correct name and description', () => { + expect(respawnTool.name).toBe('respawn'); + expect(respawnTool.description).toContain('Resets the agent context'); + }); + + it('should have correct parameter schema', () => { + expect(respawnTool.parameters.type).toBe('object'); + expect(respawnTool.parameters.properties.respawnContext).toBeDefined(); + expect(respawnTool.parameters.required).toContain('respawnContext'); + }); + + it('should execute and return confirmation message', async () => { + const result = await respawnTool.execute( + { respawnContext: 'new context' }, + { logger: mockLogger }, + ); + expect(result).toBe('Respawn initiated'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c0928e4..ce7359a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "tests/**/*.test.ts"], - environment: "node", + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + environment: 'node', globals: true, // Default timeout for all tests testTimeout: 10000,