From 17c938e6289420f31a29bf58a3cad61282d50ab9 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 10 Feb 2025 10:41:10 -0500 Subject: [PATCH 1/3] new version --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f866eb..b021eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # mycoder +## 0.0.14 + +### Patch Changes + +- Add new version check, use sub-agents for context more freely. + ## 0.0.13 ### Patch Changes diff --git a/package.json b/package.json index afc61d3..2a7f81e 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.0.13", + "version": "0.0.14", "type": "module", "bin": "./bin/cli.js", "main": "./dist/index.js", From 475961dba872bdaa5ee0e136244f99237af95925 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 10 Feb 2025 12:08:59 -0500 Subject: [PATCH 2/3] add sync shell command support, remove non-async for safety. --- package.json | 2 + pnpm-lock.yaml | 17 +++ src/tools/getTools.ts | 7 +- src/tools/system/shellMessage.test.ts | 188 ++++++++++++++++++++++++++ src/tools/system/shellMessage.ts | 180 ++++++++++++++++++++++++ src/tools/system/shellStart.test.ts | 93 +++++++++++++ src/tools/system/shellStart.ts | 163 ++++++++++++++++++++++ src/utils/mockLogger.ts | 13 ++ 8 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 src/tools/system/shellMessage.test.ts create mode 100644 src/tools/system/shellMessage.ts create mode 100644 src/tools/system/shellStart.test.ts create mode 100644 src/tools/system/shellStart.ts create mode 100644 src/utils/mockLogger.ts diff --git a/package.json b/package.json index 2a7f81e..3dcc762 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "chalk": "^5", "dotenv": "^16", "source-map-support": "^0.5", + "uuid": "^11.0.5", "yargs": "^17", "yargs-file-commands": "^0.0.17", "zod": "^3", @@ -61,6 +62,7 @@ "@changesets/cli": "^2", "@eslint/js": "^9", "@types/node": "^18", + "@types/uuid": "^10.0.0", "@types/yargs": "^17", "@typescript-eslint/eslint-plugin": "^8.23.0", "@typescript-eslint/parser": "^8.23.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cd6787..d035867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: source-map-support: specifier: ^0.5 version: 0.5.21 + uuid: + specifier: ^11.0.5 + version: 11.0.5 yargs: specifier: ^17 version: 17.7.2 @@ -42,6 +45,9 @@ importers: '@types/node': specifier: ^18 version: 18.19.75 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@types/yargs': specifier: ^17 version: 17.0.33 @@ -506,6 +512,9 @@ packages: '@types/node@18.19.75': resolution: {integrity: sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1867,6 +1876,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + vite-node@3.0.5: resolution: {integrity: sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2432,6 +2445,8 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/uuid@10.0.0': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -3970,6 +3985,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@11.0.5: {} + vite-node@3.0.5(@types/node@18.19.75): dependencies: cac: 6.7.14 diff --git a/src/tools/getTools.ts b/src/tools/getTools.ts index 408afb3..32133b7 100644 --- a/src/tools/getTools.ts +++ b/src/tools/getTools.ts @@ -1,20 +1,23 @@ import { subAgentTool } from "../tools/interaction/subAgent.js"; -import { shellExecuteTool } from "../tools/system/shellExecute.js"; import { readFileTool } from "../tools/io/readFile.js"; import { userPromptTool } from "../tools/interaction/userPrompt.js"; import { sequenceCompleteTool } from "../tools/system/sequenceComplete.js"; import { fetchTool } from "../tools/io/fetch.js"; import { Tool } from "../core/types.js"; import { updateFileTool } from "./io/updateFile.js"; +import { shellStartTool } from "./system/shellStart.js"; +import { shellMessageTool } from "./system/shellMessage.js"; export async function getTools(): Promise { return [ subAgentTool, readFileTool, updateFileTool, - shellExecuteTool, + //shellExecuteTool, - remove for now. userPromptTool, sequenceCompleteTool, fetchTool, + shellStartTool, + shellMessageTool, ] as Tool[]; } diff --git a/src/tools/system/shellMessage.test.ts b/src/tools/system/shellMessage.test.ts new file mode 100644 index 0000000..0dc765c --- /dev/null +++ b/src/tools/system/shellMessage.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { shellStartTool } from "./shellStart.js"; +import { MockLogger } from "../../utils/mockLogger.js"; +import { shellMessageTool } from "./shellMessage.js"; + +// eslint-disable-next-line max-lines-per-function +describe("shellMessageTool", () => { + const mockLogger = new MockLogger(); + + let testInstanceId = ""; + + beforeEach(() => { + global.startedProcesses.clear(); + }); + + afterEach(() => { + for (const process of global.startedProcesses.values()) { + process.kill(); + } + global.startedProcesses.clear(); + }); + + it("should interact with a running process", async () => { + // Start a test process + const startResult = await shellStartTool.execute( + { + command: "cat", // cat will echo back input + description: "Test interactive process", + }, + { logger: mockLogger } + ); + + testInstanceId = startResult.instanceId; + + // Send input and get response + const result = await shellMessageTool.execute( + { + instanceId: testInstanceId, + stdin: "hello world", + description: "Test interaction", + }, + { logger: mockLogger } + ); + + expect(result.stdout).toBe("hello world"); + expect(result.stderr).toBe(""); + expect(result.completed).toBe(false); + }); + + it("should handle nonexistent process", async () => { + const result = await shellMessageTool.execute( + { + instanceId: "nonexistent-id", + description: "Test invalid process", + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + expect(result.completed).toBe(false); + }); + + it("should handle process completion", async () => { + // Start a quick process + const startResult = await shellStartTool.execute( + { + command: 'echo "test" && exit', + description: "Test completion", + }, + { logger: mockLogger } + ); + + // Wait a moment for process to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check completion", + }, + { logger: mockLogger } + ); + + expect(result.completed).toBe(true); + // Process should still be in startedProcesses even after completion + expect(global.startedProcesses.has(startResult.instanceId)).toBe(true); + }); + + it("should handle SIGTERM signal correctly", async () => { + // Start a long-running process + const startResult = await shellStartTool.execute( + { + command: "sleep 10", + description: "Test SIGTERM handling", + }, + { logger: mockLogger } + ); + + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send SIGTERM", + }, + { logger: mockLogger } + ); + expect(result.signaled).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const result2 = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check on status", + }, + { logger: mockLogger } + ); + + expect(result2.completed).toBe(true); + expect(result2.error).toBeUndefined(); + }); + + it("should handle signals on terminated process gracefully", async () => { + // Start a process + const startResult = await shellStartTool.execute( + { + command: "sleep 1", + description: "Test signal handling on terminated process", + }, + { logger: mockLogger } + ); + + // Wait for process to complete + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Try to send signal to completed process + const result = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send signal to terminated process", + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + expect(result.signaled).toBe(false); + expect(result.completed).toBe(true); + }); + + it("should verify signaled flag after process termination", async () => { + // Start a process + const startResult = await shellStartTool.execute( + { + command: "sleep 5", + description: "Test signal flag verification", + }, + { logger: mockLogger } + ); + + // Send SIGTERM + await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + signal: "SIGTERM", + description: "Send SIGTERM", + }, + { logger: mockLogger } + ); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Check process state after signal + const checkResult = await shellMessageTool.execute( + { + instanceId: startResult.instanceId, + description: "Check signal state", + }, + { logger: mockLogger } + ); + + console.log({ checkResult }); + + expect(checkResult.signaled).toBe(true); + expect(checkResult.completed).toBe(true); + expect(global.startedProcesses.has(startResult.instanceId)).toBe(true); + }); +}); diff --git a/src/tools/system/shellMessage.ts b/src/tools/system/shellMessage.ts new file mode 100644 index 0000000..a324437 --- /dev/null +++ b/src/tools/system/shellMessage.ts @@ -0,0 +1,180 @@ +import { Tool } from "../../core/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; + +// Define valid NodeJS signals as a union type +type NodeSignals = + | 'SIGABRT' + | 'SIGALRM' + | 'SIGBUS' + | 'SIGCHLD' + | 'SIGCONT' + | 'SIGFPE' + | 'SIGHUP' + | 'SIGILL' + | 'SIGINT' + | 'SIGIO' + | 'SIGIOT' + | 'SIGKILL' + | 'SIGPIPE' + | 'SIGPOLL' + | 'SIGPROF' + | 'SIGPWR' + | 'SIGQUIT' + | 'SIGSEGV' + | 'SIGSTKFLT' + | 'SIGSTOP' + | 'SIGSYS' + | 'SIGTERM' + | 'SIGTRAP' + | 'SIGTSTP' + | 'SIGTTIN' + | 'SIGTTOU' + | 'SIGUNUSED' + | 'SIGURG' + | 'SIGUSR1' + | 'SIGUSR2' + | 'SIGVTALRM' + | 'SIGWINCH' + | 'SIGXCPU' + | 'SIGXFSZ'; + +const parameterSchema = z.object({ + instanceId: z.string().describe("The ID returned by shellStart"), + stdin: z.string().optional().describe("Input to send to process"), + signal: z.enum([ + 'SIGABRT', 'SIGALRM', 'SIGBUS', 'SIGCHLD', 'SIGCONT', + 'SIGFPE', 'SIGHUP', 'SIGILL', 'SIGINT', 'SIGIO', + 'SIGIOT', 'SIGKILL', 'SIGPIPE', 'SIGPOLL', 'SIGPROF', + 'SIGPWR', 'SIGQUIT', 'SIGSEGV', 'SIGSTKFLT', 'SIGSTOP', + 'SIGSYS', 'SIGTERM', 'SIGTRAP', 'SIGTSTP', 'SIGTTIN', + 'SIGTTOU', 'SIGUNUSED', 'SIGURG', 'SIGUSR1', 'SIGUSR2', + 'SIGVTALRM', 'SIGWINCH', 'SIGXCPU', 'SIGXFSZ' + ] as const).optional().describe("Signal to send to the process (e.g., SIGTERM, SIGINT)"), + description: z + .string() + .max(80) + .describe("The reason for this shell interaction (max 80 chars)"), +}); + +const returnSchema = z + .object({ + stdout: z.string(), + stderr: z.string(), + completed: z.boolean(), + error: z.string().optional(), + signaled: z.boolean().optional(), + }) + .describe( + "Process interaction results including stdout, stderr, and completion status" + ); + +type Parameters = z.infer; +type ReturnType = z.infer; + +export const shellMessageTool: Tool = { + name: "shellMessage", + description: + "Interacts with a running shell process, sending input and receiving output", + parameters: zodToJsonSchema(parameterSchema), + returns: zodToJsonSchema(returnSchema), + + execute: async ({ instanceId, stdin, signal }, { logger }): Promise => { + logger.verbose( + `Interacting with shell process ${instanceId}${stdin ? " with input" : ""}${signal ? ` with signal ${signal}` : ""}` + ); + + try { + const process = global.startedProcesses.get(instanceId); + if (!process) { + throw new Error(`No process found with ID ${instanceId}`); + } + + const processOutput = global.processOutputs.get(instanceId); + if (!processOutput) { + throw new Error(`No output buffers found for process ${instanceId}`); + } + + const processState = global.processState.get(instanceId); + if (!processState) { + throw new Error(`No state information found for process ${instanceId}`); + } + + // Send signal if provided + if (signal) { + const wasKilled = process.kill(signal); + if (!wasKilled) { + return { + stdout: "", + stderr: "", + completed: processState.completed, + signaled: false, + error: `Failed to send signal ${signal} to process (process may have already terminated)` + }; + } + processState.signaled = true; + } + + // Send input if provided + if (stdin) { + if (!process.stdin?.writable) { + throw new Error("Process stdin is not available"); + } + process.stdin.write(`${stdin}\n`); + } + + // Wait a brief moment for output to be processed + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get accumulated output + const stdout = processOutput.stdout.join(""); + const stderr = processOutput.stderr.join(""); + + // Clear the buffers + processOutput.stdout = []; + processOutput.stderr = []; + + logger.verbose("Interaction completed successfully"); + if (stdout) { + logger.verbose(`stdout: ${stdout.trim()}`); + } + if (stderr) { + logger.verbose(`stderr: ${stderr.trim()}`); + } + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + completed: processState.completed, + signaled: processState.signaled, + }; + } catch (error) { + if (error instanceof Error) { + logger.verbose(`Process interaction failed: ${error.message}`); + + return { + stdout: "", + stderr: "", + completed: false, + error: error.message, + }; + } + + const errorMessage = String(error); + logger.error(`Unknown error during process interaction: ${errorMessage}`); + return { + stdout: "", + stderr: "", + completed: false, + error: `Unknown error occurred: ${errorMessage}`, + }; + } + }, + + logParameters: (input, { logger }) => { + logger.info( + `Interacting with process ${input.instanceId}, ${input.description}` + ); + }, + logReturns: () => {}, +}; diff --git a/src/tools/system/shellStart.test.ts b/src/tools/system/shellStart.test.ts new file mode 100644 index 0000000..ac9b25d --- /dev/null +++ b/src/tools/system/shellStart.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { shellStartTool } from './shellStart.js'; +import { MockLogger } from '../../utils/mockLogger.js'; + +describe('shellStartTool', () => { + const mockLogger = new MockLogger(); + + beforeEach(() => { + global.startedProcesses.clear(); + }); + + afterEach(() => { + for (const process of global.startedProcesses.values()) { + process.kill(); + } + global.startedProcesses.clear(); + }); + + it('should start a process and return instance ID', async () => { + const result = await shellStartTool.execute( + { + command: 'echo "test"', + description: 'Test process', + }, + { logger: mockLogger } + ); + + expect(result.instanceId).toBeDefined(); + expect(result.error).toBeUndefined(); + }); + + it('should handle invalid commands', async () => { + const result = await shellStartTool.execute( + { + command: 'nonexistentcommand', + description: 'Invalid command test', + }, + { logger: mockLogger } + ); + + expect(result.error).toBeDefined(); + }); + + it('should keep process in startedProcesses after completion', async () => { + const result = await shellStartTool.execute( + { + command: 'echo "test"', + description: 'Completion test', + }, + { logger: mockLogger } + ); + + // Wait for process to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Process should still be in startedProcesses + expect(global.startedProcesses.has(result.instanceId)).toBe(true); + }); + + it('should handle piped commands correctly', async () => { + // Start a process that uses pipes + const result = await shellStartTool.execute( + { + command: 'grep "test"', // Just grep waiting for stdin + description: 'Pipe test', + }, + { logger: mockLogger } + ); + + expect(result.instanceId).toBeDefined(); + expect(result.error).toBeUndefined(); + + // Process should be in startedProcesses + expect(global.startedProcesses.has(result.instanceId)).toBe(true); + + // Get the process + const process = global.startedProcesses.get(result.instanceId); + expect(process).toBeDefined(); + + // Write to stdin and check output + if (process?.stdin) { + process.stdin.write('this is a test line\n'); + process.stdin.write('not matching line\n'); + process.stdin.write('another test here\n'); + + // Wait for output + await new Promise(resolve => setTimeout(resolve, 200)); + + // Process should have filtered only lines with "test" + // This part might need adjustment based on how output is captured + } + }); +}); \ No newline at end of file diff --git a/src/tools/system/shellStart.ts b/src/tools/system/shellStart.ts new file mode 100644 index 0000000..77268ae --- /dev/null +++ b/src/tools/system/shellStart.ts @@ -0,0 +1,163 @@ +import { spawn } from "child_process"; +import type { ChildProcess } from "child_process"; +import { Tool } from "../../core/types.js"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { v4 as uuidv4 } from "uuid"; + +// Global maps to store process state +declare global { + var startedProcesses: Map; + var processOutputs: Map; + var processState: Map; +} + +if (!global.startedProcesses) { + global.startedProcesses = new Map(); +} + +if (!global.processOutputs) { + global.processOutputs = new Map(); +} + +if (!global.processState) { + global.processState = new Map(); +} + +const parameterSchema = z.object({ + command: z.string().describe("The shell command to execute"), + description: z + .string() + .max(80) + .describe("The reason this shell command is being run (max 80 chars)"), +}); + +const returnSchema = z + .object({ + instanceId: z.string(), + stdout: z.string(), + stderr: z.string(), + error: z.string().optional(), + }) + .describe("Process start results including instance ID and initial output"); + +type Parameters = z.infer; +type ReturnType = z.infer; + +export const shellStartTool: Tool = { + name: "shellStart", + description: + "Starts a long-running shell command and returns a process instance ID", + parameters: zodToJsonSchema(parameterSchema), + returns: zodToJsonSchema(returnSchema), + + // eslint-disable-next-line max-lines-per-function + execute: async ({ command }, { logger }): Promise => { + logger.verbose(`Starting shell command: ${command}`); + + // eslint-disable-next-line max-lines-per-function + return new Promise((resolve) => { + try { + const instanceId = uuidv4(); + let hasResolved = false; + + // Initialize output buffers and process state + global.processOutputs.set(instanceId, { stdout: [], stderr: [] }); + global.processState.set(instanceId, { completed: false, signaled: false }); + + // Split command into command and args + // Use command directly with shell: true + const process = spawn(command, [], { shell: true }); + + // Handle process events + if (process.stdout) + process.stdout.on("data", (data) => { + const output = data.toString(); + const processOutput = global.processOutputs.get(instanceId); + if (processOutput) { + processOutput.stdout.push(output); + } + logger.verbose(`[${instanceId}] stdout: ${output.trim()}`); + }); + + if (process.stderr) + process.stderr.on("data", (data) => { + const output = data.toString(); + const processOutput = global.processOutputs.get(instanceId); + if (processOutput) { + processOutput.stderr.push(output); + } + logger.verbose(`[${instanceId}] stderr: ${output.trim()}`); + }); + + process.on("error", (error) => { + logger.error(`[${instanceId}] Process error: ${error.message}`); + const state = global.processState.get(instanceId); + if (state) { + state.completed = true; + } + if (!hasResolved) { + hasResolved = true; + const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; + resolve({ + instanceId, + stdout: outputs.stdout.join("").trim(), + stderr: outputs.stderr.join("").trim(), + error: error.message, + }); + } + }); + + process.on("exit", (code, signal) => { + logger.verbose(`[${instanceId}] Process exited with code ${code} and signal ${signal}`); + const state = global.processState.get(instanceId); + if (state) { + state.completed = true; + state.signaled = signal !== null; + } + if (code !== 0 && !hasResolved) { + hasResolved = true; + const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; + resolve({ + instanceId, + stdout: outputs.stdout.join("").trim(), + stderr: outputs.stderr.join("").trim(), + error: `Process exited with code ${code}${signal ? ` and signal ${signal}` : ""}`, + }); + } + }); + + // Store process in global map + global.startedProcesses.set(instanceId, process); + + // Set timeout to return initial results + setTimeout(() => { + if (!hasResolved) { + hasResolved = true; + const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; + resolve({ + instanceId, + stdout: outputs.stdout.join("").trim(), + stderr: outputs.stderr.join("").trim(), + }); + } + }, 1000); // Wait 1 second for initial output + } catch (error) { + logger.error(`Failed to start process: ${error}`); + resolve({ + instanceId: "", + stdout: "", + stderr: "", + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, + + logParameters: (input, { logger }) => { + logger.info(`Starting "${input.command}", ${input.description}`); + }, + logReturns: (output, { logger }) => { + logger.info(`Process started with instance ID: ${output.instanceId}`); + }, +}; diff --git a/src/utils/mockLogger.ts b/src/utils/mockLogger.ts new file mode 100644 index 0000000..30cde14 --- /dev/null +++ b/src/utils/mockLogger.ts @@ -0,0 +1,13 @@ +import { Logger } from './logger.js'; + +export class MockLogger extends Logger { + constructor() { + super({ name: 'mock' }); + } + + debug(..._messages: any[]): void {} + verbose(..._messages: any[]): void {} + info(..._messages: any[]): void {} + warn(..._messages: any[]): void {} + error(..._messages: any[]): void {} +} \ No newline at end of file From e40b0a2317f1cc522c3f473ba6147d88112e9272 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 10 Feb 2025 12:26:16 -0500 Subject: [PATCH 3/3] simplify internal structures. --- src/tools/system/shellMessage.test.ts | 18 ++- src/tools/system/shellMessage.ts | 160 +++++++++++++++----------- src/tools/system/shellStart.test.ts | 68 +++++------ src/tools/system/shellStart.ts | 93 +++++++-------- 4 files changed, 175 insertions(+), 164 deletions(-) diff --git a/src/tools/system/shellMessage.test.ts b/src/tools/system/shellMessage.test.ts index 0dc765c..844d2b6 100644 --- a/src/tools/system/shellMessage.test.ts +++ b/src/tools/system/shellMessage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { shellStartTool } from "./shellStart.js"; +import { processStates, shellStartTool } from "./shellStart.js"; import { MockLogger } from "../../utils/mockLogger.js"; import { shellMessageTool } from "./shellMessage.js"; @@ -10,14 +10,14 @@ describe("shellMessageTool", () => { let testInstanceId = ""; beforeEach(() => { - global.startedProcesses.clear(); + processStates.clear(); }); afterEach(() => { - for (const process of global.startedProcesses.values()) { - process.kill(); + for (const processState of processStates.values()) { + processState.process.kill(); } - global.startedProcesses.clear(); + processStates.clear(); }); it("should interact with a running process", async () => { @@ -82,8 +82,8 @@ describe("shellMessageTool", () => { ); expect(result.completed).toBe(true); - // Process should still be in startedProcesses even after completion - expect(global.startedProcesses.has(startResult.instanceId)).toBe(true); + // Process should still be in processStates even after completion + expect(processStates.has(startResult.instanceId)).toBe(true); }); it("should handle SIGTERM signal correctly", async () => { @@ -179,10 +179,8 @@ describe("shellMessageTool", () => { { logger: mockLogger } ); - console.log({ checkResult }); - expect(checkResult.signaled).toBe(true); expect(checkResult.completed).toBe(true); - expect(global.startedProcesses.has(startResult.instanceId)).toBe(true); + expect(processStates.has(startResult.instanceId)).toBe(true); }); }); diff --git a/src/tools/system/shellMessage.ts b/src/tools/system/shellMessage.ts index a324437..801dfa5 100644 --- a/src/tools/system/shellMessage.ts +++ b/src/tools/system/shellMessage.ts @@ -1,56 +1,87 @@ import { Tool } from "../../core/types.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; +import { processStates } from "./shellStart.js"; // Define valid NodeJS signals as a union type type NodeSignals = - | 'SIGABRT' - | 'SIGALRM' - | 'SIGBUS' - | 'SIGCHLD' - | 'SIGCONT' - | 'SIGFPE' - | 'SIGHUP' - | 'SIGILL' - | 'SIGINT' - | 'SIGIO' - | 'SIGIOT' - | 'SIGKILL' - | 'SIGPIPE' - | 'SIGPOLL' - | 'SIGPROF' - | 'SIGPWR' - | 'SIGQUIT' - | 'SIGSEGV' - | 'SIGSTKFLT' - | 'SIGSTOP' - | 'SIGSYS' - | 'SIGTERM' - | 'SIGTRAP' - | 'SIGTSTP' - | 'SIGTTIN' - | 'SIGTTOU' - | 'SIGUNUSED' - | 'SIGURG' - | 'SIGUSR1' - | 'SIGUSR2' - | 'SIGVTALRM' - | 'SIGWINCH' - | 'SIGXCPU' - | 'SIGXFSZ'; + | "SIGABRT" + | "SIGALRM" + | "SIGBUS" + | "SIGCHLD" + | "SIGCONT" + | "SIGFPE" + | "SIGHUP" + | "SIGILL" + | "SIGINT" + | "SIGIO" + | "SIGIOT" + | "SIGKILL" + | "SIGPIPE" + | "SIGPOLL" + | "SIGPROF" + | "SIGPWR" + | "SIGQUIT" + | "SIGSEGV" + | "SIGSTKFLT" + | "SIGSTOP" + | "SIGSYS" + | "SIGTERM" + | "SIGTRAP" + | "SIGTSTP" + | "SIGTTIN" + | "SIGTTOU" + | "SIGUNUSED" + | "SIGURG" + | "SIGUSR1" + | "SIGUSR2" + | "SIGVTALRM" + | "SIGWINCH" + | "SIGXCPU" + | "SIGXFSZ"; const parameterSchema = z.object({ instanceId: z.string().describe("The ID returned by shellStart"), stdin: z.string().optional().describe("Input to send to process"), - signal: z.enum([ - 'SIGABRT', 'SIGALRM', 'SIGBUS', 'SIGCHLD', 'SIGCONT', - 'SIGFPE', 'SIGHUP', 'SIGILL', 'SIGINT', 'SIGIO', - 'SIGIOT', 'SIGKILL', 'SIGPIPE', 'SIGPOLL', 'SIGPROF', - 'SIGPWR', 'SIGQUIT', 'SIGSEGV', 'SIGSTKFLT', 'SIGSTOP', - 'SIGSYS', 'SIGTERM', 'SIGTRAP', 'SIGTSTP', 'SIGTTIN', - 'SIGTTOU', 'SIGUNUSED', 'SIGURG', 'SIGUSR1', 'SIGUSR2', - 'SIGVTALRM', 'SIGWINCH', 'SIGXCPU', 'SIGXFSZ' - ] as const).optional().describe("Signal to send to the process (e.g., SIGTERM, SIGINT)"), + signal: z + .enum([ + "SIGABRT", + "SIGALRM", + "SIGBUS", + "SIGCHLD", + "SIGCONT", + "SIGFPE", + "SIGHUP", + "SIGILL", + "SIGINT", + "SIGIO", + "SIGIOT", + "SIGKILL", + "SIGPIPE", + "SIGPOLL", + "SIGPROF", + "SIGPWR", + "SIGQUIT", + "SIGSEGV", + "SIGSTKFLT", + "SIGSTOP", + "SIGSYS", + "SIGTERM", + "SIGTRAP", + "SIGTSTP", + "SIGTTIN", + "SIGTTOU", + "SIGUNUSED", + "SIGURG", + "SIGUSR1", + "SIGUSR2", + "SIGVTALRM", + "SIGWINCH", + "SIGXCPU", + "SIGXFSZ", + ] as const) + .optional() + .describe("Signal to send to the process (e.g., SIGTERM, SIGINT)"), description: z .string() .max(80) @@ -79,60 +110,53 @@ export const shellMessageTool: Tool = { parameters: zodToJsonSchema(parameterSchema), returns: zodToJsonSchema(returnSchema), - execute: async ({ instanceId, stdin, signal }, { logger }): Promise => { + execute: async ( + { instanceId, stdin, signal }, + { logger } + ): Promise => { logger.verbose( `Interacting with shell process ${instanceId}${stdin ? " with input" : ""}${signal ? ` with signal ${signal}` : ""}` ); try { - const process = global.startedProcesses.get(instanceId); - if (!process) { - throw new Error(`No process found with ID ${instanceId}`); - } - - const processOutput = global.processOutputs.get(instanceId); - if (!processOutput) { - throw new Error(`No output buffers found for process ${instanceId}`); - } - - const processState = global.processState.get(instanceId); + const processState = processStates.get(instanceId); if (!processState) { - throw new Error(`No state information found for process ${instanceId}`); + throw new Error(`No process found with ID ${instanceId}`); } // Send signal if provided if (signal) { - const wasKilled = process.kill(signal); + const wasKilled = processState.process.kill(signal); if (!wasKilled) { return { stdout: "", stderr: "", - completed: processState.completed, + completed: processState.state.completed, signaled: false, - error: `Failed to send signal ${signal} to process (process may have already terminated)` + error: `Failed to send signal ${signal} to process (process may have already terminated)`, }; } - processState.signaled = true; + processState.state.signaled = true; } // Send input if provided if (stdin) { - if (!process.stdin?.writable) { + if (!processState.process.stdin?.writable) { throw new Error("Process stdin is not available"); } - process.stdin.write(`${stdin}\n`); + processState.process.stdin.write(`${stdin}\n`); } // Wait a brief moment for output to be processed await new Promise((resolve) => setTimeout(resolve, 100)); // Get accumulated output - const stdout = processOutput.stdout.join(""); - const stderr = processOutput.stderr.join(""); + const stdout = processState.stdout.join(""); + const stderr = processState.stderr.join(""); // Clear the buffers - processOutput.stdout = []; - processOutput.stderr = []; + processState.stdout = []; + processState.stderr = []; logger.verbose("Interaction completed successfully"); if (stdout) { @@ -145,8 +169,8 @@ export const shellMessageTool: Tool = { return { stdout: stdout.trim(), stderr: stderr.trim(), - completed: processState.completed, - signaled: processState.signaled, + completed: processState.state.completed, + signaled: processState.state.signaled, }; } catch (error) { if (error instanceof Error) { diff --git a/src/tools/system/shellStart.test.ts b/src/tools/system/shellStart.test.ts index ac9b25d..a615b90 100644 --- a/src/tools/system/shellStart.test.ts +++ b/src/tools/system/shellStart.test.ts @@ -1,26 +1,26 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { shellStartTool } from './shellStart.js'; -import { MockLogger } from '../../utils/mockLogger.js'; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { processStates, shellStartTool } from "./shellStart.js"; +import { MockLogger } from "../../utils/mockLogger.js"; -describe('shellStartTool', () => { +describe("shellStartTool", () => { const mockLogger = new MockLogger(); beforeEach(() => { - global.startedProcesses.clear(); + processStates.clear(); }); afterEach(() => { - for (const process of global.startedProcesses.values()) { - process.kill(); + for (const processState of processStates.values()) { + processState.process.kill(); } - global.startedProcesses.clear(); + processStates.clear(); }); - it('should start a process and return instance ID', async () => { + it("should start a process and return instance ID", async () => { const result = await shellStartTool.execute( { command: 'echo "test"', - description: 'Test process', + description: "Test process", }, { logger: mockLogger } ); @@ -29,11 +29,11 @@ describe('shellStartTool', () => { expect(result.error).toBeUndefined(); }); - it('should handle invalid commands', async () => { + it("should handle invalid commands", async () => { const result = await shellStartTool.execute( { - command: 'nonexistentcommand', - description: 'Invalid command test', + command: "nonexistentcommand", + description: "Invalid command test", }, { logger: mockLogger } ); @@ -41,28 +41,28 @@ describe('shellStartTool', () => { expect(result.error).toBeDefined(); }); - it('should keep process in startedProcesses after completion', async () => { + it("should keep process in processStates after completion", async () => { const result = await shellStartTool.execute( { command: 'echo "test"', - description: 'Completion test', + description: "Completion test", }, { logger: mockLogger } ); // Wait for process to complete - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); - // Process should still be in startedProcesses - expect(global.startedProcesses.has(result.instanceId)).toBe(true); + // Process should still be in processStates + expect(processStates.has(result.instanceId)).toBe(true); }); - it('should handle piped commands correctly', async () => { + it("should handle piped commands correctly", async () => { // Start a process that uses pipes const result = await shellStartTool.execute( { - command: 'grep "test"', // Just grep waiting for stdin - description: 'Pipe test', + command: 'grep "test"', // Just grep waiting for stdin + description: "Pipe test", }, { logger: mockLogger } ); @@ -70,24 +70,24 @@ describe('shellStartTool', () => { expect(result.instanceId).toBeDefined(); expect(result.error).toBeUndefined(); - // Process should be in startedProcesses - expect(global.startedProcesses.has(result.instanceId)).toBe(true); + // Process should be in processStates + expect(processStates.has(result.instanceId)).toBe(true); // Get the process - const process = global.startedProcesses.get(result.instanceId); - expect(process).toBeDefined(); - + const processState = processStates.get(result.instanceId); + expect(processState).toBeDefined(); + // Write to stdin and check output - if (process?.stdin) { - process.stdin.write('this is a test line\n'); - process.stdin.write('not matching line\n'); - process.stdin.write('another test here\n'); - + if (processState?.process.stdin) { + processState.process.stdin.write("this is a test line\n"); + processState.process.stdin.write("not matching line\n"); + processState.process.stdin.write("another test here\n"); + // Wait for output - await new Promise(resolve => setTimeout(resolve, 200)); - + await new Promise((resolve) => setTimeout(resolve, 200)); + // Process should have filtered only lines with "test" // This part might need adjustment based on how output is captured } }); -}); \ No newline at end of file +}); diff --git a/src/tools/system/shellStart.ts b/src/tools/system/shellStart.ts index 77268ae..d764b15 100644 --- a/src/tools/system/shellStart.ts +++ b/src/tools/system/shellStart.ts @@ -5,24 +5,22 @@ import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { v4 as uuidv4 } from "uuid"; -// Global maps to store process state -declare global { - var startedProcesses: Map; - var processOutputs: Map; - var processState: Map; -} +// Define ProcessState type +type ProcessState = { + process: ChildProcess; -if (!global.startedProcesses) { - global.startedProcesses = new Map(); -} + stdout: string[]; + stderr: string[]; -if (!global.processOutputs) { - global.processOutputs = new Map(); -} + state: { + completed: boolean; + signaled: boolean; + }; +}; + +// Global map to store process state -if (!global.processState) { - global.processState = new Map(); -} +export const processStates: Map = new Map(); const parameterSchema = z.object({ command: z.string().describe("The shell command to execute"), @@ -47,98 +45,89 @@ type ReturnType = z.infer; export const shellStartTool: Tool = { name: "shellStart", description: - "Starts a long-running shell command and returns a process instance ID", + "Starts a long-running shell command and returns a process instance ID, progress can be checked with shellMessage command", parameters: zodToJsonSchema(parameterSchema), returns: zodToJsonSchema(returnSchema), - // eslint-disable-next-line max-lines-per-function execute: async ({ command }, { logger }): Promise => { logger.verbose(`Starting shell command: ${command}`); - // eslint-disable-next-line max-lines-per-function return new Promise((resolve) => { try { const instanceId = uuidv4(); let hasResolved = false; - // Initialize output buffers and process state - global.processOutputs.set(instanceId, { stdout: [], stderr: [] }); - global.processState.set(instanceId, { completed: false, signaled: false }); - // Split command into command and args // Use command directly with shell: true const process = spawn(command, [], { shell: true }); + const processState: ProcessState = { + process, + stdout: [], + stderr: [], + state: { completed: false, signaled: false }, + }; + + // Initialize combined process state + processStates.set(instanceId, processState); + // Handle process events if (process.stdout) process.stdout.on("data", (data) => { const output = data.toString(); - const processOutput = global.processOutputs.get(instanceId); - if (processOutput) { - processOutput.stdout.push(output); - } + processState.stdout.push(output); logger.verbose(`[${instanceId}] stdout: ${output.trim()}`); }); if (process.stderr) process.stderr.on("data", (data) => { const output = data.toString(); - const processOutput = global.processOutputs.get(instanceId); - if (processOutput) { - processOutput.stderr.push(output); - } + processState.stderr.push(output); logger.verbose(`[${instanceId}] stderr: ${output.trim()}`); }); process.on("error", (error) => { logger.error(`[${instanceId}] Process error: ${error.message}`); - const state = global.processState.get(instanceId); - if (state) { - state.completed = true; - } + processState.state.completed = true; if (!hasResolved) { hasResolved = true; - const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; resolve({ instanceId, - stdout: outputs.stdout.join("").trim(), - stderr: outputs.stderr.join("").trim(), + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), error: error.message, }); } }); process.on("exit", (code, signal) => { - logger.verbose(`[${instanceId}] Process exited with code ${code} and signal ${signal}`); - const state = global.processState.get(instanceId); - if (state) { - state.completed = true; - state.signaled = signal !== null; - } + logger.verbose( + `[${instanceId}] Process exited with code ${code} and signal ${signal}` + ); + + processState.state.completed = true; + processState.state.signaled = signal !== null; + if (code !== 0 && !hasResolved) { hasResolved = true; - const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; + resolve({ instanceId, - stdout: outputs.stdout.join("").trim(), - stderr: outputs.stderr.join("").trim(), + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), error: `Process exited with code ${code}${signal ? ` and signal ${signal}` : ""}`, }); } }); - // Store process in global map - global.startedProcesses.set(instanceId, process); - // Set timeout to return initial results setTimeout(() => { if (!hasResolved) { hasResolved = true; - const outputs = global.processOutputs.get(instanceId) || { stdout: [], stderr: [] }; resolve({ instanceId, - stdout: outputs.stdout.join("").trim(), - stderr: outputs.stderr.join("").trim(), + stdout: processState.stdout.join("").trim(), + stderr: processState.stderr.join("").trim(), }); } }, 1000); // Wait 1 second for initial output