diff --git a/.github/workflows/publish-js.yml b/.github/workflows/publish-js.yml index a11d3a137..b2ed9b0f2 100644 --- a/.github/workflows/publish-js.yml +++ b/.github/workflows/publish-js.yml @@ -145,6 +145,18 @@ jobs: crates/bashkit-js/index.d.cts crates/bashkit-js/wrapper.js crates/bashkit-js/wrapper.d.ts + crates/bashkit-js/scripted.js + crates/bashkit-js/scripted.d.ts + crates/bashkit-js/internal.js + crates/bashkit-js/internal.d.ts + crates/bashkit-js/langchain.js + crates/bashkit-js/langchain.d.ts + crates/bashkit-js/anthropic.js + crates/bashkit-js/anthropic.d.ts + crates/bashkit-js/openai.js + crates/bashkit-js/openai.d.ts + crates/bashkit-js/ai.js + crates/bashkit-js/ai.d.ts crates/bashkit-js/bashkit.wasi.cjs crates/bashkit-js/bashkit.wasi-browser.js crates/bashkit-js/wasi-worker.mjs diff --git a/crates/bashkit-js/.gitignore b/crates/bashkit-js/.gitignore index 5d47fda02..818508515 100644 --- a/crates/bashkit-js/.gitignore +++ b/crates/bashkit-js/.gitignore @@ -20,11 +20,19 @@ bashkit.wasi-browser.js wasi-worker.mjs wasi-worker-browser.mjs -# tsc output (source is wrapper.ts, langchain.ts, ai.ts, anthropic.ts, openai.ts) +# tsc output (source is wrapper.ts, scripted.ts, internal.ts, langchain.ts, ai.ts, anthropic.ts, openai.ts) wrapper.js wrapper.d.ts wrapper.d.ts.map wrapper.js.map +scripted.js +scripted.d.ts +scripted.d.ts.map +scripted.js.map +internal.js +internal.d.ts +internal.d.ts.map +internal.js.map langchain.js langchain.d.ts langchain.d.ts.map diff --git a/crates/bashkit-js/README.md b/crates/bashkit-js/README.md index 2e55cd053..5a0f7fc6d 100644 --- a/crates/bashkit-js/README.md +++ b/crates/bashkit-js/README.md @@ -283,7 +283,7 @@ console.log(result.stdout); Use `ScriptedTool` to register JavaScript callbacks as bash-callable tools: ```typescript -import { ScriptedTool } from "@everruns/bashkit"; +import { ScriptedTool } from "@everruns/bashkit/scripted"; const tool = new ScriptedTool({ name: "api" }); tool.addTool("get_user", "Fetch user by ID", (params) => { diff --git a/crates/bashkit-js/__test__/integration.spec.ts b/crates/bashkit-js/__test__/integration.spec.ts index cecf52e43..bad1ff7d4 100644 --- a/crates/bashkit-js/__test__/integration.spec.ts +++ b/crates/bashkit-js/__test__/integration.spec.ts @@ -1,5 +1,6 @@ import test from "ava"; -import { Bash, BashError, BashTool, ScriptedTool } from "../wrapper.js"; +import { Bash, BashError, BashTool } from "../wrapper.js"; +import { ScriptedTool } from "../scripted.js"; test("integration: python opt-in executes embedded Python", (t) => { const bash = new Bash({ python: true }); diff --git a/crates/bashkit-js/__test__/runtime-compat/_setup.mjs b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs index a8bde9b0c..b42f35350 100644 --- a/crates/bashkit-js/__test__/runtime-compat/_setup.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/_setup.mjs @@ -6,7 +6,6 @@ export { Bash, BashTool, BashError, - ScriptedTool, getVersion, snapshotAncestry, snapshotCapabilities, @@ -16,3 +15,4 @@ export { snapshotPlanCheckout, snapshotReachable, } from "../../wrapper.js"; +export { ScriptedTool } from "../../scripted.js"; diff --git a/crates/bashkit-js/__test__/scripted-entry.spec.ts b/crates/bashkit-js/__test__/scripted-entry.spec.ts new file mode 100644 index 000000000..100518944 --- /dev/null +++ b/crates/bashkit-js/__test__/scripted-entry.spec.ts @@ -0,0 +1,23 @@ +// ScriptedTool lives in @everruns/bashkit/scripted; the root keeps a +// deprecated alias of the same class during the transition. +import test from "ava"; +import { ScriptedTool } from "../scripted.js"; +import * as root from "../wrapper.js"; + +test("scripted entry: ScriptedTool runs registered tools", async (t) => { + const tool = new ScriptedTool({ name: "api" }); + tool.addTool("greet", "Greet user", (params) => `hi ${params.name}\n`); + const result = await tool.execute("greet --name ada"); + t.is(result.stdout, "hi ada\n"); +}); + +test("root entry: deprecated alias is the same class", (t) => { + t.is(root.ScriptedTool, ScriptedTool); + t.true(new root.ScriptedTool({ name: "x" }) instanceof ScriptedTool); +}); + +test("scripted entry: BashError is the shared root class", async (t) => { + const tool = new ScriptedTool({ name: "api" }); + const error = await t.throwsAsync(() => tool.executeOrThrow("exit 3")); + t.true(error instanceof root.BashError); +}); diff --git a/crates/bashkit-js/__test__/security.spec.ts b/crates/bashkit-js/__test__/security.spec.ts index bbeaff50e..c24c58ca6 100644 --- a/crates/bashkit-js/__test__/security.spec.ts +++ b/crates/bashkit-js/__test__/security.spec.ts @@ -11,7 +11,8 @@ */ import test from "ava"; -import { Bash, BashTool, BashError, ScriptedTool } from "../wrapper.js"; +import { Bash, BashTool, BashError } from "../wrapper.js"; +import { ScriptedTool } from "../scripted.js"; function sleepMs(ms: number): void { const signal = new Int32Array(new SharedArrayBuffer(4)); diff --git a/crates/bashkit-js/internal.ts b/crates/bashkit-js/internal.ts new file mode 100644 index 000000000..aabf83ef5 --- /dev/null +++ b/crates/bashkit-js/internal.ts @@ -0,0 +1,128 @@ +// Shared runtime helpers for wrapper.ts and scripted.ts. +// +// Decision: helpers used by both the root entry (Bash/BashTool) and the +// `@everruns/bashkit/scripted` entry live here so neither entry imports the +// other (no ESM cycle). Not a public entry point: package.json exports only +// ".", "./scripted" and the framework adapters. + +import { createRequire } from "node:module"; +import type { ExecResult } from "./index.cjs"; + +const require = createRequire(import.meta.url); +/** @internal Native addon module. */ +export const native = require("./index.cjs"); + +export const MAX_JSON_NESTING_DEPTH = 64; + +export const DEFAULT_MAX_INPUT_BYTES = 10_000_000; +const MAX_PENDING_ASYNC_EXECUTIONS = 8; +const ASYNC_EXECUTE_QUEUE_FULL_ERROR = + "too many pending async execute calls for this instance"; +interface AsyncExecuteQueueState { + tail: Promise; + pending: number; +} +const asyncExecuteQueues = new WeakMap(); +export function errorExecResult(error: string): ExecResult { + return { + stdout: "", + stdoutBytes: [], + stderr: error, + stderrBytes: Array.from(Buffer.from(error, "utf8")), + exitCode: 1, + error, + stdoutTruncated: false, + stderrTruncated: false, + finalEnv: undefined, + success: false, + }; +} + +export function inputTooLargeExecResult( + commands: string, + maxInputBytes: number, +): ExecResult | undefined { + const inputBytes = Buffer.byteLength(commands, "utf8"); + if (inputBytes <= maxInputBytes) { + return undefined; + } + return errorExecResult( + `input too large: ${inputBytes} bytes exceeds maxInputBytes ${maxInputBytes}`, + ); +} + +// Decision: serialize async execute() per instance in JS so queued AbortSignal +// listeners only attach once a call reaches the front of the line. Also bound +// the backlog before retaining large command strings in queued closures. +export function queueAsyncExecute( + owner: object, + run: () => Promise, +): Promise { + let state = asyncExecuteQueues.get(owner); + if (!state) { + state = { tail: Promise.resolve(), pending: 0 }; + asyncExecuteQueues.set(owner, state); + } + if (state.pending >= MAX_PENDING_ASYNC_EXECUTIONS) { + return Promise.reject(new Error(ASYNC_EXECUTE_QUEUE_FULL_ERROR)); + } + state.pending += 1; + const previous = state.tail; + const completion = previous.then( + () => run(), + () => run(), + ); + state.tail = completion.then( + () => undefined, + () => undefined, + ); + state.tail.finally(() => { + state.pending -= 1; + if (state.pending === 0 && asyncExecuteQueues.get(owner) === state) { + asyncExecuteQueues.delete(owner); + } + }); + return completion; +} + +export function validateJsonNestingDepth(value: unknown, depth = 0): void { + if (depth > MAX_JSON_NESTING_DEPTH) { + throw new RangeError( + `JSON nesting depth exceeds maximum of ${MAX_JSON_NESTING_DEPTH}`, + ); + } + + if (Array.isArray(value)) { + for (const item of value) { + validateJsonNestingDepth(item, depth + 1); + } + return; + } + + if (value && typeof value === "object") { + for (const item of Object.values(value as Record)) { + validateJsonNestingDepth(item, depth + 1); + } + } +} + +/** + * Error thrown when a bash command execution fails. + */ +export class BashError extends Error { + readonly exitCode: number; + readonly stderr: string; + + constructor(result: ExecResult) { + const message = + result.error ?? result.stderr ?? `Exit code ${result.exitCode}`; + super(message); + this.name = "BashError"; + this.exitCode = result.exitCode; + this.stderr = result.stderr; + } + + display(): string { + return `BashError(exit_code=${this.exitCode}): ${this.message}`; + } +} diff --git a/crates/bashkit-js/langchain.ts b/crates/bashkit-js/langchain.ts index f7f13903f..d567df968 100644 --- a/crates/bashkit-js/langchain.ts +++ b/crates/bashkit-js/langchain.ts @@ -13,7 +13,7 @@ * const result = await tool.invoke({ commands: "echo hello" }); * * // Scripted tool - * import { ScriptedTool } from '@everruns/bashkit'; + * import { ScriptedTool } from '@everruns/bashkit/scripted'; * const st = new ScriptedTool({ name: "api" }); * st.addTool("greet", "Greet user", (p) => `hello ${p.name}\n`); * const langchainTool = createScriptedTool(st); @@ -24,7 +24,8 @@ import { DynamicStructuredTool } from "@langchain/core/tools"; import { z } from "zod"; -import { BashTool, ScriptedTool } from "./wrapper.js"; +import { BashTool } from "./wrapper.js"; +import { ScriptedTool } from "./scripted.js"; import type { BashOptions } from "./wrapper.js"; const bashInputSchema = z.object({ @@ -102,7 +103,7 @@ export function createBashTool( * * @example * ```typescript - * import { ScriptedTool } from '@everruns/bashkit'; + * import { ScriptedTool } from '@everruns/bashkit/scripted'; * import { createScriptedTool } from '@everruns/bashkit/langchain'; * * const st = new ScriptedTool({ name: "api" }); diff --git a/crates/bashkit-js/package.json b/crates/bashkit-js/package.json index eaa468bf2..d40f42578 100644 --- a/crates/bashkit-js/package.json +++ b/crates/bashkit-js/package.json @@ -13,6 +13,9 @@ "browser": "./bashkit.wasi-browser.js", "default": "./wrapper.js" }, + "./scripted": { + "default": "./scripted.js" + }, "./langchain": { "default": "./langchain.js" }, @@ -52,6 +55,10 @@ "files": [ "wrapper.js", "wrapper.d.ts", + "scripted.js", + "scripted.d.ts", + "internal.js", + "internal.d.ts", "langchain.js", "langchain.d.ts", "anthropic.js", diff --git a/crates/bashkit-js/scripted.ts b/crates/bashkit-js/scripted.ts new file mode 100644 index 000000000..c0a876248 --- /dev/null +++ b/crates/bashkit-js/scripted.ts @@ -0,0 +1,225 @@ +// Decision: `ScriptedTool` ships from the `@everruns/bashkit/scripted` entry, +// mirroring the Rust `bashkit-scripted-tool` crate; the root entry keeps the +// interpreter and `BashTool`. The root still re-exports it as a deprecated +// alias during the transition. + +/** + * Multi-tool orchestration for Bashkit (`ScriptedTool`). + * + * Register JS callbacks as bash builtins; one bash script composes them with + * pipes, loops, and `jq` in a logic-only shell. + * + * @packageDocumentation + */ + +import type { + ExecResult, + ScriptedTool as NativeScriptedToolType, +} from "./index.cjs"; +import { + BashError, + DEFAULT_MAX_INPUT_BYTES, + inputTooLargeExecResult, + native, + queueAsyncExecute, + validateJsonNestingDepth, +} from "./internal.js"; + +export type { ExecResult }; + +const NativeScriptedTool: typeof NativeScriptedToolType = native.ScriptedTool; + +/** + * Options for creating a ScriptedTool instance. + */ +export interface ScriptedToolOptions { + name: string; + shortDescription?: string; + maxCommands?: number; + maxLoopIterations?: number; +} + +/** + * Callback type for ScriptedTool tool commands. + * + * Receives parsed `--key value` flags as `params` and optional piped input as `stdin`. + * Must return a string. + */ +export type ToolCallback = ( + params: Record, + stdin: string | null, +) => string; + +/** + * Compose JS callbacks as bash builtins for multi-tool orchestration. + * + * Each registered tool becomes a bash builtin command. An LLM (or user) writes + * a single bash script that pipes, loops, and branches across all tools. + * + * @example + * ```typescript + * import { ScriptedTool } from '@everruns/bashkit/scripted'; + * + * const tool = new ScriptedTool({ name: "api" }); + * tool.addTool("greet", "Greet user", + * (params) => `hello ${params.name ?? "world"}\n` + * ); + * const result = tool.executeSync("greet --name Alice"); + * console.log(result.stdout); // hello Alice\n + * ``` + */ +export class ScriptedTool { + private native: NativeScriptedToolType; + // Keep strong JS refs while native TSFN callbacks are weak. + private callbackRefs: Array<(requestJson: string) => string> = []; + + constructor(options: ScriptedToolOptions) { + this.native = new NativeScriptedTool({ + name: options.name, + shortDescription: options.shortDescription, + maxCommands: options.maxCommands, + maxLoopIterations: options.maxLoopIterations, + }); + } + + /** + * Register a tool command. + * + * @param name - Command name (becomes a bash builtin) + * @param description - Human-readable description + * @param callback - JS function `(params, stdin) => string` + * @param schema - Optional JSON Schema for input parameters + */ + addTool( + name: string, + description: string, + callback: ToolCallback, + schema?: Record, + ): void { + if (schema) { + validateJsonNestingDepth(schema); + } + // Wrap the user callback to handle JSON serialization protocol + const wrappedCallback = (requestJson: string): string => { + const request = JSON.parse(requestJson) as { + params: Record; + stdin: string | null; + }; + return callback(request.params, request.stdin); + }; + this.callbackRefs.push(wrappedCallback); + this.native.addTool( + name, + description, + wrappedCallback, + schema ? JSON.stringify(schema) : undefined, + ); + } + + /** + * Add an environment variable visible inside scripts. + */ + env(key: string, value: string): void { + this.native.env(key, value); + } + + /** + * Execute a bash script synchronously. + * + * Note: ScriptedTool callbacks run asynchronously via Node's event loop. + * If a registered tool is invoked, this method returns a non-zero result + * instead of queueing a callback that would deadlock. Use `execute()` + * (async) for scripts that call registered tools. Only use this for scripts + * that don't invoke any registered tools (e.g., pure bash). + */ + executeSync(commands: string): ExecResult { + return this.native.executeSync(commands); + } + + /** + * Execute a bash script asynchronously, returning a Promise. + * + * This is the recommended execution method for ScriptedTool since + * tool callbacks require the Node.js event loop to be running. + */ + async execute(commands: string): Promise { + const inputLimitResult = inputTooLargeExecResult( + commands, + DEFAULT_MAX_INPUT_BYTES, + ); + if (inputLimitResult) { + return inputLimitResult; + } + return queueAsyncExecute(this, () => this.native.execute(commands)); + } + + /** + * Execute synchronously. Throws `BashError` on non-zero exit. + * + * Same caveats as `executeSync()` — throws when a registered tool would + * require the blocked Node event loop. Use `executeOrThrow()` instead. + */ + executeSyncOrThrow(commands: string): ExecResult { + const result = this.native.executeSync(commands); + if (result.exitCode !== 0) { + throw new BashError(result); + } + return result; + } + + /** + * Execute asynchronously. Throws `BashError` on non-zero exit. + */ + async executeOrThrow(commands: string): Promise { + const result = await this.execute(commands); + if (result.exitCode !== 0) { + throw new BashError(result); + } + return result; + } + + /** Tool name. */ + get name(): string { + return this.native.name; + } + + /** Short description. */ + get shortDescription(): string { + return this.native.shortDescription; + } + + /** Number of registered tools. */ + toolCount(): number { + return this.native.toolCount(); + } + + /** Token-efficient tool description. */ + description(): string { + return this.native.description(); + } + + /** Markdown help document. */ + help(): string { + return this.native.help(); + } + + /** Compact system prompt for orchestration. */ + systemPrompt(): string { + return this.native.systemPrompt(); + } + + /** JSON input schema as string. */ + inputSchema(): string { + return this.native.inputSchema(); + } + + /** JSON output schema as string. */ + outputSchema(): string { + return this.native.outputSchema(); + } + + /** Tool version. */ + get version(): string { + return this.native.version; + } +} diff --git a/crates/bashkit-js/tsconfig.check.json b/crates/bashkit-js/tsconfig.check.json index 02c584434..193a4bced 100644 --- a/crates/bashkit-js/tsconfig.check.json +++ b/crates/bashkit-js/tsconfig.check.json @@ -8,6 +8,8 @@ }, "include": [ "wrapper.ts", + "scripted.ts", + "internal.ts", "langchain.ts", "anthropic.ts", "openai.ts", diff --git a/crates/bashkit-js/tsconfig.json b/crates/bashkit-js/tsconfig.json index d17b255eb..6287d6d11 100644 --- a/crates/bashkit-js/tsconfig.json +++ b/crates/bashkit-js/tsconfig.json @@ -13,6 +13,6 @@ "noUnusedLocals": true, "noUnusedParameters": true }, - "include": ["wrapper.ts", "langchain.ts", "anthropic.ts", "openai.ts", "ai.ts"], + "include": ["wrapper.ts", "scripted.ts", "internal.ts", "langchain.ts", "anthropic.ts", "openai.ts", "ai.ts"], "exclude": ["node_modules", "__test__"] } diff --git a/crates/bashkit-js/wrapper.ts b/crates/bashkit-js/wrapper.ts index 7d18b8460..f06fdab8c 100644 --- a/crates/bashkit-js/wrapper.ts +++ b/crates/bashkit-js/wrapper.ts @@ -1,9 +1,7 @@ import { AsyncResource } from "node:async_hooks"; -import { createRequire } from "node:module"; import type { Bash as NativeBashType, BashTool as NativeBashToolType, - ScriptedTool as NativeScriptedToolType, ExecResult, BashOptions as NativeBashOptions, SnapshotOptions as NativeSnapshotOptions, @@ -17,8 +15,16 @@ import type { AnalyzedRedirect, ExecutionProfileName as NativeExecutionProfileName, } from "./index.cjs"; +import { + BashError, + DEFAULT_MAX_INPUT_BYTES, + inputTooLargeExecResult, + native, + queueAsyncExecute, +} from "./internal.js"; export type { ScriptAnalysis, AnalyzedCommand, AnalyzedRedirect }; +export { BashError }; /** Closed typed execution-policy selectors. */ export const ExecutionProfile = Object.freeze({ @@ -29,11 +35,8 @@ export const ExecutionProfile = Object.freeze({ export type ExecutionProfileName = (typeof ExecutionProfile)[keyof typeof ExecutionProfile]; -const require = createRequire(import.meta.url); -const native = require("./index.cjs"); const NativeBash: typeof NativeBashType = native.Bash; const NativeBashTool: typeof NativeBashToolType = native.BashTool; -const NativeScriptedTool: typeof NativeScriptedToolType = native.ScriptedTool; const nativeGetVersion: () => string = native.getVersion; const nativeCreateFileSystem: () => any = native.__createFileSystem; const nativeRealFileSystem: ( @@ -126,8 +129,6 @@ export type { */ export type FileValue = string | (() => string) | (() => Promise); -const MAX_JSON_NESTING_DEPTH = 64; - /** * Execution context passed to a custom builtin registered via * `customBuiltins` or `addBuiltin`. @@ -379,15 +380,6 @@ export interface ExecuteOptions { type NativeOnOutput = (chunkPair: [string, string]) => string | undefined; const ASYNC_ON_OUTPUT_ERROR = "onOutput must be synchronous and must not return a Promise"; -const DEFAULT_MAX_INPUT_BYTES = 10_000_000; -const MAX_PENDING_ASYNC_EXECUTIONS = 8; -const ASYNC_EXECUTE_QUEUE_FULL_ERROR = - "too many pending async execute calls for this instance"; -interface AsyncExecuteQueueState { - tail: Promise; - pending: number; -} -const asyncExecuteQueues = new WeakMap(); function isAsyncFunction(fn: Function): boolean { return Object.prototype.toString.call(fn) === "[object AsyncFunction]"; @@ -401,21 +393,6 @@ function isPromiseLike(value: unknown): value is PromiseLike { ); } -function errorExecResult(error: string): ExecResult { - return { - stdout: "", - stdoutBytes: [], - stderr: error, - stderrBytes: Array.from(Buffer.from(error, "utf8")), - exitCode: 1, - error, - stdoutTruncated: false, - stderrTruncated: false, - finalEnv: undefined, - success: false, - }; -} - function cancelledExecResult(): ExecResult { // Preserve prior behavior: cancellation does not populate stderr. return { @@ -432,53 +409,6 @@ function cancelledExecResult(): ExecResult { }; } -function inputTooLargeExecResult( - commands: string, - maxInputBytes: number, -): ExecResult | undefined { - const inputBytes = Buffer.byteLength(commands, "utf8"); - if (inputBytes <= maxInputBytes) { - return undefined; - } - return errorExecResult( - `input too large: ${inputBytes} bytes exceeds maxInputBytes ${maxInputBytes}`, - ); -} - -// Decision: serialize async execute() per instance in JS so queued AbortSignal -// listeners only attach once a call reaches the front of the line. Also bound -// the backlog before retaining large command strings in queued closures. -function queueAsyncExecute( - owner: object, - run: () => Promise, -): Promise { - let state = asyncExecuteQueues.get(owner); - if (!state) { - state = { tail: Promise.resolve(), pending: 0 }; - asyncExecuteQueues.set(owner, state); - } - if (state.pending >= MAX_PENDING_ASYNC_EXECUTIONS) { - return Promise.reject(new Error(ASYNC_EXECUTE_QUEUE_FULL_ERROR)); - } - state.pending += 1; - const previous = state.tail; - const completion = previous.then( - () => run(), - () => run(), - ); - state.tail = completion.then( - () => undefined, - () => undefined, - ); - state.tail.finally(() => { - state.pending -= 1; - if (state.pending === 0 && asyncExecuteQueues.get(owner) === state) { - asyncExecuteQueues.delete(owner); - } - }); - return completion; -} - function bindOnOutputToCurrentAsyncContext(onOutput: OnOutput): OnOutput { return AsyncResource.bind(onOutput) as OnOutput; } @@ -542,27 +472,6 @@ async function resolveFiles( return resolved; } -function validateJsonNestingDepth(value: unknown, depth = 0): void { - if (depth > MAX_JSON_NESTING_DEPTH) { - throw new RangeError( - `JSON nesting depth exceeds maximum of ${MAX_JSON_NESTING_DEPTH}`, - ); - } - - if (Array.isArray(value)) { - for (const item of value) { - validateJsonNestingDepth(item, depth + 1); - } - return; - } - - if (value && typeof value === "object") { - for (const item of Object.values(value as Record)) { - validateJsonNestingDepth(item, depth + 1); - } - } -} - /** * Resolve file values synchronously. Throws if any value is async. */ @@ -643,27 +552,6 @@ function isFileSystemLike(value: unknown): value is { toExternal(): unknown } { ); } -/** - * Error thrown when a bash command execution fails. - */ -export class BashError extends Error { - readonly exitCode: number; - readonly stderr: string; - - constructor(result: ExecResult) { - const message = - result.error ?? result.stderr ?? `Exit code ${result.exitCode}`; - super(message); - this.name = "BashError"; - this.exitCode = result.exitCode; - this.stderr = result.stderr; - } - - display(): string { - return `BashError(exit_code=${this.exitCode}): ${this.message}`; - } -} - /** * Standalone filesystem handle for direct VFS operations and native addon interop. */ @@ -1918,200 +1806,23 @@ export class BashTool { } } -/** - * Options for creating a ScriptedTool instance. - */ -export interface ScriptedToolOptions { - name: string; - shortDescription?: string; - maxCommands?: number; - maxLoopIterations?: number; -} - -/** - * Callback type for ScriptedTool tool commands. - * - * Receives parsed `--key value` flags as `params` and optional piped input as `stdin`. - * Must return a string. - */ -export type ToolCallback = ( - params: Record, - stdin: string | null, -) => string; - -/** - * Compose JS callbacks as bash builtins for multi-tool orchestration. - * - * Each registered tool becomes a bash builtin command. An LLM (or user) writes - * a single bash script that pipes, loops, and branches across all tools. - * - * @example - * ```typescript - * import { ScriptedTool } from '@everruns/bashkit'; - * - * const tool = new ScriptedTool({ name: "api" }); - * tool.addTool("greet", "Greet user", - * (params) => `hello ${params.name ?? "world"}\n` - * ); - * const result = tool.executeSync("greet --name Alice"); - * console.log(result.stdout); // hello Alice\n - * ``` - */ -export class ScriptedTool { - private native: NativeScriptedToolType; - // Keep strong JS refs while native TSFN callbacks are weak. - private callbackRefs: Array<(requestJson: string) => string> = []; - - constructor(options: ScriptedToolOptions) { - this.native = new NativeScriptedTool({ - name: options.name, - shortDescription: options.shortDescription, - maxCommands: options.maxCommands, - maxLoopIterations: options.maxLoopIterations, - }); - } - - /** - * Register a tool command. - * - * @param name - Command name (becomes a bash builtin) - * @param description - Human-readable description - * @param callback - JS function `(params, stdin) => string` - * @param schema - Optional JSON Schema for input parameters - */ - addTool( - name: string, - description: string, - callback: ToolCallback, - schema?: Record, - ): void { - if (schema) { - validateJsonNestingDepth(schema); - } - // Wrap the user callback to handle JSON serialization protocol - const wrappedCallback = (requestJson: string): string => { - const request = JSON.parse(requestJson) as { - params: Record; - stdin: string | null; - }; - return callback(request.params, request.stdin); - }; - this.callbackRefs.push(wrappedCallback); - this.native.addTool( - name, - description, - wrappedCallback, - schema ? JSON.stringify(schema) : undefined, - ); - } - - /** - * Add an environment variable visible inside scripts. - */ - env(key: string, value: string): void { - this.native.env(key, value); - } - - /** - * Execute a bash script synchronously. - * - * Note: ScriptedTool callbacks run asynchronously via Node's event loop. - * If a registered tool is invoked, this method returns a non-zero result - * instead of queueing a callback that would deadlock. Use `execute()` - * (async) for scripts that call registered tools. Only use this for scripts - * that don't invoke any registered tools (e.g., pure bash). - */ - executeSync(commands: string): ExecResult { - return this.native.executeSync(commands); - } - - /** - * Execute a bash script asynchronously, returning a Promise. - * - * This is the recommended execution method for ScriptedTool since - * tool callbacks require the Node.js event loop to be running. - */ - async execute(commands: string): Promise { - const inputLimitResult = inputTooLargeExecResult( - commands, - DEFAULT_MAX_INPUT_BYTES, - ); - if (inputLimitResult) { - return inputLimitResult; - } - return queueAsyncExecute(this, () => this.native.execute(commands)); - } - - /** - * Execute synchronously. Throws `BashError` on non-zero exit. - * - * Same caveats as `executeSync()` — throws when a registered tool would - * require the blocked Node event loop. Use `executeOrThrow()` instead. - */ - executeSyncOrThrow(commands: string): ExecResult { - const result = this.native.executeSync(commands); - if (result.exitCode !== 0) { - throw new BashError(result); - } - return result; - } - - /** - * Execute asynchronously. Throws `BashError` on non-zero exit. - */ - async executeOrThrow(commands: string): Promise { - const result = await this.execute(commands); - if (result.exitCode !== 0) { - throw new BashError(result); - } - return result; - } - - /** Tool name. */ - get name(): string { - return this.native.name; - } - - /** Short description. */ - get shortDescription(): string { - return this.native.shortDescription; - } - - /** Number of registered tools. */ - toolCount(): number { - return this.native.toolCount(); - } - - /** Token-efficient tool description. */ - description(): string { - return this.native.description(); - } - - /** Markdown help document. */ - help(): string { - return this.native.help(); - } - - /** Compact system prompt for orchestration. */ - systemPrompt(): string { - return this.native.systemPrompt(); - } - - /** JSON input schema as string. */ - inputSchema(): string { - return this.native.inputSchema(); - } - - /** JSON output schema as string. */ - outputSchema(): string { - return this.native.outputSchema(); - } - - /** Tool version. */ - get version(): string { - return this.native.version; - } -} +import { + ScriptedTool as ScriptedToolClass, + type ScriptedToolOptions as ScriptedToolOptionsType, + type ToolCallback as ToolCallbackType, +} from "./scripted.js"; + +// Transition aliases: ScriptedTool moved to `@everruns/bashkit/scripted`. +// TODO: remove after one release cycle (see docs/migrating-scripted-tool.md). + +/** @deprecated Import `ScriptedTool` from `@everruns/bashkit/scripted`. */ +export const ScriptedTool = ScriptedToolClass; +/** @deprecated Import `ScriptedTool` from `@everruns/bashkit/scripted`. */ +export type ScriptedTool = ScriptedToolClass; +/** @deprecated Import `ScriptedToolOptions` from `@everruns/bashkit/scripted`. */ +export type ScriptedToolOptions = ScriptedToolOptionsType; +/** @deprecated Import `ToolCallback` from `@everruns/bashkit/scripted`. */ +export type ToolCallback = ToolCallbackType; /** * How strictly a checkout enforces the capability fingerprint recorded in a diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 52792e3b1..006b9029a 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -470,7 +470,7 @@ an internal private loop and should not be called from an async endpoint. Use `ScriptedTool` to register Python callbacks as bash-callable tools: ```python -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool def get_user(params, stdin=None): diff --git a/crates/bashkit-python/bashkit/__init__.py b/crates/bashkit-python/bashkit/__init__.py index 8d8374f87..38c81a7be 100644 --- a/crates/bashkit-python/bashkit/__init__.py +++ b/crates/bashkit-python/bashkit/__init__.py @@ -18,9 +18,9 @@ Hello, World! >>> print(tool.input_schema()) # JSON Schema for LLM function calling -Multi-tool orchestration (``ScriptedTool``):: +Multi-tool orchestration (``ScriptedTool``, in ``bashkit.scripted``):: - >>> from bashkit import ScriptedTool + >>> from bashkit.scripted import ScriptedTool >>> tool = ScriptedTool("api") >>> tool.add_tool("greet", "Greet user", ... callback=lambda p, s=None: f"hello {p.get('name', 'world')}\\n") @@ -57,7 +57,6 @@ FileSystem, PackedCommit, ScriptAnalysis, - ScriptedTool, ShellState, SnapshotDiff, SnapshotGraph, @@ -81,9 +80,28 @@ "PackedCommit", "ShellState", "ScriptAnalysis", - "ScriptedTool", "SnapshotDiff", "SnapshotGraph", "create_langchain_tool_spec", "get_version", ] + + +_MOVED = {"ScriptedTool": "bashkit.scripted"} + + +def __getattr__(name: str): + # Transition shim: ScriptedTool moved to bashkit.scripted. + # TODO: remove after one release cycle (see docs/migrating-scripted-tool.md). + if name in _MOVED: + import importlib + import warnings + + target = _MOVED[name] + warnings.warn( + f"bashkit.{name} moved to {target}; use `from {target} import {name}`", + FutureWarning, + stacklevel=2, + ) + return getattr(importlib.import_module(target), name) + raise AttributeError(f"module 'bashkit' has no attribute {name!r}") diff --git a/crates/bashkit-python/bashkit/langchain.py b/crates/bashkit-python/bashkit/langchain.py index 7c58cb601..402206763 100644 --- a/crates/bashkit-python/bashkit/langchain.py +++ b/crates/bashkit-python/bashkit/langchain.py @@ -23,7 +23,7 @@ Wrap a ScriptedTool for multi-tool orchestration:: - >>> from bashkit import ScriptedTool + >>> from bashkit.scripted import ScriptedTool >>> from bashkit.langchain import create_scripted_tool >>> >>> st = ScriptedTool("api") @@ -57,7 +57,7 @@ def PrivateAttr(*args, **kwargs): from bashkit import BashTool as NativeBashTool -from bashkit import ScriptedTool as NativeScriptedTool +from bashkit.scripted import ScriptedTool as NativeScriptedTool class BashToolInput(BaseModel): @@ -153,7 +153,7 @@ class ScriptedToolLangChain(BaseTool): registered sub-tools in one call. Example: - >>> from bashkit import ScriptedTool + >>> from bashkit.scripted import ScriptedTool >>> st = ScriptedTool("k8s") >>> st.add_tool("get_pods", "List pods", callback=my_callback) >>> tool = ScriptedToolLangChain(st) @@ -280,7 +280,7 @@ def create_scripted_tool(scripted_tool: NativeScriptedTool) -> ScriptedToolLangC ImportError: If langchain-core is not installed Example: - >>> from bashkit import ScriptedTool + >>> from bashkit.scripted import ScriptedTool >>> from bashkit.langchain import create_scripted_tool >>> >>> st = ScriptedTool("api") diff --git a/crates/bashkit-python/bashkit/scripted.py b/crates/bashkit-python/bashkit/scripted.py new file mode 100644 index 000000000..ee94503fa --- /dev/null +++ b/crates/bashkit-python/bashkit/scripted.py @@ -0,0 +1,24 @@ +""" +Multi-tool orchestration for Bashkit (``ScriptedTool``). + +Each registered Python callback becomes a bash builtin; an LLM writes one bash +script that pipes, loops, and branches across all of them in a logic-only +shell. + + >>> from bashkit.scripted import ScriptedTool + >>> tool = ScriptedTool("api") + >>> tool.add_tool("greet", "Greet user", + ... callback=lambda p, s=None: f"hello {p.get('name', 'world')}\\n") + >>> result = tool.execute_sync("greet --name Alice") + >>> print(result.stdout.strip()) + hello Alice + +Decision: ``ScriptedTool`` lives here, not in the top-level ``bashkit`` +namespace, mirroring the Rust ``bashkit-scripted-tool`` crate. ``bashkit`` +keeps the interpreter and ``BashTool``. ``bashkit.ScriptedTool`` still resolves +with a ``FutureWarning`` during the transition. +""" + +from bashkit._bashkit import ScriptedTool + +__all__ = ["ScriptedTool"] diff --git a/crates/bashkit-python/examples/fastapi_async_tool.py b/crates/bashkit-python/examples/fastapi_async_tool.py index 48f39443e..56c81342f 100644 --- a/crates/bashkit-python/examples/fastapi_async_tool.py +++ b/crates/bashkit-python/examples/fastapi_async_tool.py @@ -34,7 +34,7 @@ import uvicorn from fastapi import FastAPI, Request -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool # --------------------------------------------------------------------------- # Request-scoped ContextVar (like Flask's g or Starlette's request state) diff --git a/crates/bashkit-python/examples/k8s_orchestrator.py b/crates/bashkit-python/examples/k8s_orchestrator.py index d3c594dfd..9770b525c 100644 --- a/crates/bashkit-python/examples/k8s_orchestrator.py +++ b/crates/bashkit-python/examples/k8s_orchestrator.py @@ -24,7 +24,7 @@ import json import sys -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool # ============================================================================= # Fake k8s data diff --git a/crates/bashkit-python/examples/langgraph_async_tool.py b/crates/bashkit-python/examples/langgraph_async_tool.py index dd4506ff2..437196e9e 100644 --- a/crates/bashkit-python/examples/langgraph_async_tool.py +++ b/crates/bashkit-python/examples/langgraph_async_tool.py @@ -32,7 +32,7 @@ from langchain_core.tools import tool from langgraph.graph import END, StateGraph -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool # --------------------------------------------------------------------------- # ContextVar — simulates LangGraph's get_stream_writer() pattern. diff --git a/crates/bashkit-python/tests/_bashkit_categories.py b/crates/bashkit-python/tests/_bashkit_categories.py index 060ec99aa..e063fbf0d 100644 --- a/crates/bashkit-python/tests/_bashkit_categories.py +++ b/crates/bashkit-python/tests/_bashkit_categories.py @@ -11,10 +11,10 @@ BashError, BashTool, FileSystem, - ScriptedTool, ShellState, create_langchain_tool_spec, ) +from bashkit.scripted import ScriptedTool # =========================================================================== # Bash: Core interpreter diff --git a/crates/bashkit-python/tests/_security_advanced.py b/crates/bashkit-python/tests/_security_advanced.py index 243446569..d5b6adf52 100644 --- a/crates/bashkit-python/tests/_security_advanced.py +++ b/crates/bashkit-python/tests/_security_advanced.py @@ -18,7 +18,8 @@ import pytest -from bashkit import Bash, BashTool, ScriptedTool +from bashkit import Bash, BashTool +from bashkit.scripted import ScriptedTool # =========================================================================== # 1. BLACK-BOX: Creative sandbox escape attempts diff --git a/crates/bashkit-python/tests/_security_core.py b/crates/bashkit-python/tests/_security_core.py index 359869d79..351ce38ae 100644 --- a/crates/bashkit-python/tests/_security_core.py +++ b/crates/bashkit-python/tests/_security_core.py @@ -8,7 +8,8 @@ import pytest -from bashkit import Bash, BashTool, ScriptedTool +from bashkit import Bash, BashTool +from bashkit.scripted import ScriptedTool # Decision: keep the issue-1264 parity cases in this hidden core module while # the public `test_security.py` module re-exports the merged security suite. diff --git a/crates/bashkit-python/tests/test_ai_adapters.py b/crates/bashkit-python/tests/test_ai_adapters.py index 33a272cbb..da4e84b7d 100644 --- a/crates/bashkit-python/tests/test_ai_adapters.py +++ b/crates/bashkit-python/tests/test_ai_adapters.py @@ -6,7 +6,7 @@ import pytest -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool # =========================================================================== # langchain.py tests diff --git a/crates/bashkit-python/tests/test_async_callbacks.py b/crates/bashkit-python/tests/test_async_callbacks.py index f067e740c..88d7dd685 100644 --- a/crates/bashkit-python/tests/test_async_callbacks.py +++ b/crates/bashkit-python/tests/test_async_callbacks.py @@ -17,7 +17,7 @@ import pytest -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool # --------------------------------------------------------------------------- # ContextVar used across tests @@ -550,7 +550,7 @@ def test_async_callback_execute_sync_first_private_loop_call_does_not_deadlock() script = textwrap.dedent(""" import asyncio - from bashkit import ScriptedTool + from bashkit.scripted import ScriptedTool async def greet(params, stdin=None): return "hello\\n" diff --git a/crates/bashkit-python/tests/test_fastapi_integration.py b/crates/bashkit-python/tests/test_fastapi_integration.py index 1dacd6abb..d506317e6 100644 --- a/crates/bashkit-python/tests/test_fastapi_integration.py +++ b/crates/bashkit-python/tests/test_fastapi_integration.py @@ -19,7 +19,8 @@ from fastapi import FastAPI, Request # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 -from bashkit import Bash, FileSystem, ScriptedTool # noqa: E402 +from bashkit import Bash, FileSystem # noqa: E402 +from bashkit.scripted import ScriptedTool # noqa: E402 # --------------------------------------------------------------------------- # ContextVar for request-scoped state diff --git a/crates/bashkit-python/tests/test_integration.py b/crates/bashkit-python/tests/test_integration.py index 49296c7ce..85a80f954 100644 --- a/crates/bashkit-python/tests/test_integration.py +++ b/crates/bashkit-python/tests/test_integration.py @@ -11,7 +11,8 @@ import pytest -from bashkit import Bash, BashTool, ScriptedTool +from bashkit import Bash, BashTool +from bashkit.scripted import ScriptedTool # =========================================================================== # Multi-step workflows diff --git a/crates/bashkit-python/tests/test_jupyter_compat.py b/crates/bashkit-python/tests/test_jupyter_compat.py index 1486c8bcc..c9fdd9543 100644 --- a/crates/bashkit-python/tests/test_jupyter_compat.py +++ b/crates/bashkit-python/tests/test_jupyter_compat.py @@ -24,7 +24,8 @@ import pytest -from bashkit import Bash, BashTool, BuiltinContext, ScriptedTool +from bashkit import Bash, BashTool, BuiltinContext +from bashkit.scripted import ScriptedTool trace_id: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id") diff --git a/crates/bashkit-python/tests/test_langgraph_integration.py b/crates/bashkit-python/tests/test_langgraph_integration.py index 6a767c0b8..647f8f3fa 100644 --- a/crates/bashkit-python/tests/test_langgraph_integration.py +++ b/crates/bashkit-python/tests/test_langgraph_integration.py @@ -18,7 +18,8 @@ from langchain_core.tools import StructuredTool # noqa: E402 from langgraph.graph import END, StateGraph # noqa: E402 -from bashkit import Bash, FileSystem, ScriptedTool # noqa: E402 +from bashkit import Bash, FileSystem # noqa: E402 +from bashkit.scripted import ScriptedTool # noqa: E402 # --------------------------------------------------------------------------- # Simulate LangGraph's get_stream_writer() pattern diff --git a/crates/bashkit-python/tests/test_python_security.py b/crates/bashkit-python/tests/test_python_security.py index 8d256538b..8d4da6bc8 100644 --- a/crates/bashkit-python/tests/test_python_security.py +++ b/crates/bashkit-python/tests/test_python_security.py @@ -195,7 +195,7 @@ class TestJsonNesting: def test_deep_dict_nesting_rejected(self): """ScriptedTool callbacks should reject >64 levels of nesting.""" - from bashkit import ScriptedTool + from bashkit.scripted import ScriptedTool # Build a deeply nested dict def deep_callback(params, stdin=None): @@ -213,7 +213,7 @@ def deep_callback(params, stdin=None): def test_normal_nesting_works(self): """Reasonable nesting levels should work fine.""" - from bashkit import ScriptedTool + from bashkit.scripted import ScriptedTool def shallow_callback(params, stdin=None): return '{"a": {"b": {"c": "value"}}}' diff --git a/crates/bashkit-python/tests/test_scripted_namespace.py b/crates/bashkit-python/tests/test_scripted_namespace.py new file mode 100644 index 000000000..454c1d614 --- /dev/null +++ b/crates/bashkit-python/tests/test_scripted_namespace.py @@ -0,0 +1,51 @@ +"""ScriptedTool lives in bashkit.scripted; bashkit.ScriptedTool is a warning shim.""" + +import importlib +import warnings + +import pytest + +import bashkit +from bashkit.scripted import ScriptedTool + + +def test_scripted_module_exports_scripted_tool(): + import bashkit.scripted as scripted + + assert scripted.__all__ == ["ScriptedTool"] + tool = ScriptedTool("api") + tool.add_tool("greet", "Greet user", callback=lambda p, s=None: "hi\n") + assert tool.execute_sync("greet").stdout == "hi\n" + + +def test_top_level_scripted_tool_warns_and_resolves_same_class(): + with pytest.warns(FutureWarning, match="bashkit.scripted"): + legacy = bashkit.ScriptedTool + assert legacy is ScriptedTool + + +def test_top_level_from_import_warns(): + with pytest.warns(FutureWarning): + exec("from bashkit import ScriptedTool as _legacy", {}) + + +def test_scripted_tool_not_in_top_level_all(): + assert "ScriptedTool" not in bashkit.__all__ + + +def test_star_import_does_not_warn(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + exec("from bashkit import *", {}) + + +def test_unknown_attribute_still_raises(): + with pytest.raises(AttributeError, match="no attribute 'NoSuchThing'"): + bashkit.NoSuchThing # noqa: B018 + + +def test_core_imports_do_not_warn(): + with warnings.catch_warnings(): + warnings.simplefilter("error") + importlib.reload(bashkit) + from bashkit import Bash, BashTool # noqa: F401 diff --git a/crates/bashkit-python/tests/test_teardown_determinism.py b/crates/bashkit-python/tests/test_teardown_determinism.py index 36df3c243..1c57395c0 100644 --- a/crates/bashkit-python/tests/test_teardown_determinism.py +++ b/crates/bashkit-python/tests/test_teardown_determinism.py @@ -18,7 +18,8 @@ import pytest -from bashkit import Bash, ScriptedTool +from bashkit import Bash +from bashkit.scripted import ScriptedTool PROC_AVAILABLE = os.path.exists("/proc/self/task") @@ -169,7 +170,7 @@ async def slow(params, stdin=None): _EXIT_SCRIPT_CLEAN = """ import asyncio -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool async def cb(params, stdin=None): await asyncio.sleep(0) @@ -183,7 +184,7 @@ async def cb(params, stdin=None): _EXIT_SCRIPT_ABANDONED = """ import asyncio -from bashkit import ScriptedTool +from bashkit.scripted import ScriptedTool async def slow(params, stdin=None): await asyncio.sleep(30) diff --git a/docs/migrating-scripted-tool.md b/docs/migrating-scripted-tool.md index de5ff5a20..d6d566c20 100644 --- a/docs/migrating-scripted-tool.md +++ b/docs/migrating-scripted-tool.md @@ -81,10 +81,38 @@ let bash = registry.install(Bash::builder().limits(limits)).build(); Configure limits on the builder before `install`: Python and TypeScript tool bridges read the builder's execution profile at install time. -## Python and JavaScript +## Python -No change in this release: the bindings re-export the same classes from the -same import paths. +Import from `bashkit.scripted`: + +```python +# Before +from bashkit import ScriptedTool + +# After +from bashkit.scripted import ScriptedTool +``` + +The old import still works for now and emits a `FutureWarning`. Run your tests +with `-W error::FutureWarning` to find the remaining call sites. The class, its +methods, and `bashkit.langchain.create_scripted_tool` are unchanged. + +## JavaScript and TypeScript + +Import from the `@everruns/bashkit/scripted` entry: + +```typescript +// Before +import { ScriptedTool } from "@everruns/bashkit"; + +// After +import { ScriptedTool } from "@everruns/bashkit/scripted"; +``` + +The root export is kept as a `@deprecated` alias of the same class, so +`instanceof` checks keep working, and editors flag the old import. The +`ScriptedToolOptions` and `ToolCallback` types move the same way. +`createScriptedTool` stays in `@everruns/bashkit/langchain`. ## See also diff --git a/examples/k8s_orchestrator.mjs b/examples/k8s_orchestrator.mjs index ca6fea5df..7f2fb4dcf 100644 --- a/examples/k8s_orchestrator.mjs +++ b/examples/k8s_orchestrator.mjs @@ -12,7 +12,7 @@ * node examples/k8s_orchestrator.mjs */ -import { ScriptedTool } from "@everruns/bashkit"; +import { ScriptedTool } from "@everruns/bashkit/scripted"; // ============================================================================= // Fake k8s data diff --git a/examples/langchain_integration.mjs b/examples/langchain_integration.mjs index 09c2e1e78..e8429ade2 100644 --- a/examples/langchain_integration.mjs +++ b/examples/langchain_integration.mjs @@ -14,7 +14,7 @@ */ import { createBashTool, createScriptedTool } from "@everruns/bashkit/langchain"; -import { ScriptedTool } from "@everruns/bashkit"; +import { ScriptedTool } from "@everruns/bashkit/scripted"; import { ChatOpenAI } from "@langchain/openai"; import { createReactAgent } from "@langchain/langgraph/prebuilt"; diff --git a/knowledge/integrations/scripted-tool-orchestration.md b/knowledge/integrations/scripted-tool-orchestration.md index 8467bff87..0235ee238 100644 --- a/knowledge/integrations/scripted-tool-orchestration.md +++ b/knowledge/integrations/scripted-tool-orchestration.md @@ -26,6 +26,12 @@ Compose tool definitions (`ToolDef`) + execution callbacks into a single `Script Separate crate `bashkit-scripted-tool` (lib `bashkit_scripted_tool`), published to crates.io in lockstep with `bashkit` (was the `scripted_tool` feature of core until 0.18.x). Decision: core stays the sandboxed interpreter + `BashTool`; everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`, the logic-only shell profile) lives here, built only on core public APIs. Features: `python`, `typescript`, `jq` (forward to core), `tracing`. `ToolRegistry::install(builder)` replaces the old `BashBuilder::tool_registry`; it reads `BashBuilder::execution_profile()` for Python/TypeScript limits and registers `python`/`python3` + the TypeScript extension with the registry's external handler + prelude. Core extension points it depends on: `RuntimeCallContext`, `ShellFeatures` + `builtin_filter`, `Python::with_external_handler_and_prelude`, `TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `bashkit::time` (wasm-safe `Instant`/`timeout`/`sleep`). Migration guide: `docs/migrating-scripted-tool.md`. +## Bindings + +Python: `from bashkit.scripted import ScriptedTool` (`bashkit/scripted.py` re-exports the native class). `bashkit.ScriptedTool` resolves through a module `__getattr__` that emits `FutureWarning`; it is not in `bashkit.__all__`, so `from bashkit import *` stays silent. + +JS: `import { ScriptedTool } from "@everruns/bashkit/scripted"` (`scripted.ts`, `exports["./scripted"]`). The root keeps `@deprecated` aliases (`ScriptedTool`, `ScriptedToolOptions`, `ToolCallback`) bound to the same class. Helpers shared with `Bash`/`BashTool` (native loader, input-size guard, async queue, `BashError`) live in non-exported `internal.ts`, so neither entry imports the other and there is no ESM cycle. Generated API references render `bashkit.scripted` / `@everruns/bashkit/scripted` as their own sections and skip deprecated aliases. Both shims are transition-only, removal tracked by `TODO` in `bashkit/__init__.py` and `wrapper.ts`. + ## Motivation With many tools, each LLM tool call is a separate round-trip; a 5-tool data-gathering task costs 5+ turns. `ScriptedTool` lets the LLM write one bash script that calls all tools, pipes results through `jq`, and returns composed output, reducing latency and token cost. diff --git a/knowledge/log.md b/knowledge/log.md index 0ff298557..7284a9aff 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-06 +* **Decision**: Step 3 of the extraction. Python `ScriptedTool` moves to `bashkit.scripted` (old `bashkit.ScriptedTool` warns `FutureWarning` via module `__getattr__`), JS to the `@everruns/bashkit/scripted` entry (root keeps `@deprecated` aliases of the same class; shared helpers in non-exported `internal.ts` avoid an ESM cycle). Found while packing: `publish-js.yml`'s `js-stubs` artifact carried only `wrapper.*`, so npm 0.18.2 shipped without the `langchain`/`ai`/`anthropic`/`openai` entry files it exports; the artifact now lists every `tsc` output, guarded by `scripts/tests/test_js_publish_files.py`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md) and [Release Process](operations/release-process.md). * **Decision**: Step 2 of the extraction landed. `crates/bashkit-scripted-tool` (published, lockstep version) now holds `ToolDef`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet` and the logic-only shell; the core `scripted_tool` feature is removed (it was an alias for `bash_tool`, so the core dependency graph is unchanged). `BashBuilder::tool_registry` became `ToolRegistry::install(builder)`. Two more generic core public APIs made that possible without crate-private access: `bashkit::time` (the wasm-safe `Instant`/`SystemTime`/`timeout`/`sleep` shim) and `BashBuilder::execution_profile()` (read the Python/TypeScript limits a builder carries). Publish order is now `bashkit` → `bashkit-scripted-tool` + `bashkit-cli`; `just release-check` dry-runs the new crate against the latest published core. Release prep owes a CHANGELOG Breaking Changes note linking `docs/migrating-scripted-tool.md`. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Release Process](operations/release-process.md), [Architecture](foundations/architecture.md). * **Decision**: Everything built on `ToolDef` (`ToolDef`/`ToolArgs`/`ToolImpl`, `ToolDefExtension`, `ToolRegistry`, `ScriptedTool`, `ScriptingToolSet`) leaves the core crate for a published `bashkit-scripted-tool` crate; Python/JS move the class to `bashkit.scripted` / `@everruns/bashkit/scripted`, with a public migration guide. Step 1 adds only generic public extension points to core: `RuntimeCallContext` (request extensions, budget, lease and deadline for Python/TypeScript host functions, replacing the registry's private task-local), public `Python`/`TypeScriptExtension::with_external_handler_and_prelude`, `ExecutionCapability::derive`, `ExecutionBudget::run`, `BuiltinContext::{run_budgeted, remaining_deadline}`, generic restricted-shell switches `BashBuilder::shell_features(ShellFeatures)` (file redirects, process substitution, script execution) and `BashBuilder::builtin_filter`, which replace the interpreter-level `ShellProfile::LogicOnly`; the logic-only shell itself (allowlist + rejecting filesystem) now lives beside `ScriptedTool` and moves with it, public `ExecutionPlan`/`SubCommand` re-exports, and the `Tool` contract helpers. Recorded in [Scripted Tool Orchestration](integrations/scripted-tool-orchestration.md), [Python Builtin](runtimes/python-builtin.md), [ZapCode Runtime](runtimes/zapcode-runtime.md), [Tool Contract](integrations/tool-contract.md). * **Feature**: [In-Process Terminal](integrations/in-process-terminal.md) behind the `terminal` feature (off by default): `Terminal` drives a `Bash` session from raw keystrokes, reports `Idle` when the session waits for input, and exposes the screen as text through the `vt100` crate. libghostty was considered and rejected because it needs Zig and C FFI, which would break the wasm and binding builds. The new `vi` builtin reads the device in raw mode and edits VFS files. Time blocked on terminal input is excluded from the execution timeout so an editor session is not killed while an agent thinks; busy work still times out (TM-DOS-120). Buffers are bounded (TM-DOS-119). Gaps recorded as L-TERM-001..003: vi is a subset, `read` does not wait for typed input, Ctrl-C lands at the next command boundary. `less` and `more` page interactively only inside a `Terminal` and stay cat-like for `exec()`/`BashTool`/CLI callers, so no existing caller can block on a pager (L-TERM-004 covers redirected stdout inside a session). diff --git a/knowledge/operations/release-process.md b/knowledge/operations/release-process.md index a9beda306..b9f41f70a 100644 --- a/knowledge/operations/release-process.md +++ b/knowledge/operations/release-process.md @@ -289,6 +289,12 @@ recovered by re-dispatching from `main`: fix the cause and cut a patch release, whose tag then publishes `latest`. v0.17.0 landed on `next` this way, and v0.17.1 is what put the native package back on `latest`. +The publish job never runs `tsc`; it ships whatever the `js-stubs` artifact +carries. That artifact must list every `tsc` output (`.js` + `.d.ts` for each +`tsconfig.json` include). It once listed only `wrapper.*`, so 0.18.2 shipped +without `langchain.js`/`ai.js`/`anthropic.js`/`openai.js` while `package.json` +exported them. Guarded by `scripts/tests/test_js_publish_files.py`. + ### publish-wasm.yml Dispatched by `release.yml` from the verified release tag. Builds the diff --git a/scripts/gen_python_apidocs.py b/scripts/gen_python_apidocs.py index 08b4c6fe7..70b225d7b 100644 --- a/scripts/gen_python_apidocs.py +++ b/scripts/gen_python_apidocs.py @@ -27,12 +27,12 @@ PKG_SEARCH_PATH = REPO_ROOT / "crates" / "bashkit-python" OUT_PATH = REPO_ROOT / "site" / "src" / "content" / "apidocs" / "python.md" -# Curated public surface, in rendering order. Mirrors bashkit.__all__ plus the -# framework integration modules that ship in the package. +# Curated public surface, in rendering order. Mirrors bashkit.__all__, +# bashkit.scripted, plus the framework integration modules that ship in the +# package. CORE_ORDER = [ "Bash", "BashTool", - "ScriptedTool", "FileSystem", "ExecResult", "ShellState", @@ -43,6 +43,8 @@ "get_version", ] +SCRIPTED_ORDER = ["ScriptedTool"] + INTEGRATIONS = [ ("bashkit.langchain", "bashkit.langchain"), ("bashkit.pydantic_ai", "bashkit.pydantic_ai"), @@ -269,6 +271,23 @@ def main() -> int: name, member, heading=f"{name}()", level=2 ) + # bashkit.scripted re-exports the native ScriptedTool class. + out.append("---") + out.append("") + out.append("# `bashkit.scripted`") + out.append("") + out.append( + "Multi-tool orchestration. Import with " + "`from bashkit.scripted import ScriptedTool`." + ) + out.append("") + for name in SCRIPTED_ORDER: + member = native.members.get(name) + if member is None: + print(f"warning: {name} not found in stubs", file=sys.stderr) + continue + out += render_class(name, member) + # Framework integration modules (pure-Python). integ_lines: list[str] = [] for mod_path, title in INTEGRATIONS: diff --git a/scripts/gen_ts_apidocs.mjs b/scripts/gen_ts_apidocs.mjs index 1032fd183..af1311fbe 100644 --- a/scripts/gen_ts_apidocs.mjs +++ b/scripts/gen_ts_apidocs.mjs @@ -26,11 +26,12 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const jsDir = path.join(repoRoot, "crates", "bashkit-js"); const outPath = path.join(repoRoot, "site", "src", "content", "apidocs", "typescript.md"); -const ENTRY_POINTS = ["wrapper.ts", "langchain.ts", "anthropic.ts", "openai.ts", "ai.ts"]; +const ENTRY_POINTS = ["wrapper.ts", "scripted.ts", "langchain.ts", "anthropic.ts", "openai.ts", "ai.ts"]; // Friendly module titles + import specifiers, in render order. const MODULES = { wrapper: { title: null, specifier: "@everruns/bashkit", core: true }, + scripted: { title: "@everruns/bashkit/scripted", specifier: "@everruns/bashkit/scripted" }, langchain: { title: "@everruns/bashkit/langchain", specifier: "@everruns/bashkit/langchain" }, anthropic: { title: "@everruns/bashkit/anthropic", specifier: "@everruns/bashkit/anthropic" }, openai: { title: "@everruns/bashkit/openai", specifier: "@everruns/bashkit/openai" }, @@ -52,7 +53,7 @@ const Kind = { }; // Preferred ordering for the core module's classes. -const CLASS_ORDER = ["Bash", "BashTool", "ScriptedTool", "FileSystem", "BashError"]; +const CLASS_ORDER = ["Bash", "BashTool", "FileSystem", "BashError"]; function runTypedoc() { const tmp = mkdtempSync(path.join(tmpdir(), "bashkit-tsdoc-")); @@ -309,7 +310,11 @@ function renderTypeAlias(refl) { } function renderModuleMembers(mod, out) { - const children = mod.children ?? []; + // Deprecated transition aliases (e.g. the root `ScriptedTool`) stay out of + // the reference; the canonical export is documented in its own module. + const isDeprecated = (c) => + (c.comment?.blockTags ?? []).some((t) => t.tag === "@deprecated"); + const children = (mod.children ?? []).filter((c) => !isDeprecated(c)); const byKind = (k) => children.filter((c) => c.kind === k); const classes = byKind(Kind.Class).sort((a, b) => { @@ -373,7 +378,7 @@ function main() { } } if (integ.length) { - out.push("---", "", "# Framework integrations", "", ...integ); + out.push("---", "", "# Subpath modules", "", ...integ); } mkdirSync(path.dirname(outPath), { recursive: true }); diff --git a/scripts/tests/test_js_publish_files.py b/scripts/tests/test_js_publish_files.py new file mode 100644 index 000000000..8f0759917 --- /dev/null +++ b/scripts/tests/test_js_publish_files.py @@ -0,0 +1,57 @@ +"""Every tsc output the npm package lists must reach the publish job. + +Regression: publish-js.yml carried only wrapper.js/.d.ts in its `js-stubs` +artifact, so `@everruns/bashkit@0.18.2` shipped without langchain.js, ai.js, +anthropic.js and openai.js even though package.json exports them. The publish +job never runs tsc itself; whatever the artifact omits is missing on npm. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[2] +JS = ROOT / "crates/bashkit-js" + + +def stub_paths() -> set[str]: + workflow = (ROOT / ".github/workflows/publish-js.yml").read_text() + block = re.search(r"name: js-stubs\n\s+path: \|\n((?:\s+crates/bashkit-js/\S+\n)+)", workflow) + assert block, "js-stubs upload block not found" + return {line.strip().removeprefix("crates/bashkit-js/") for line in block.group(1).splitlines()} + + +class JsPublishFilesTests(unittest.TestCase): + def test_every_ts_entry_output_is_uploaded(self) -> None: + tsconfig = json.loads((JS / "tsconfig.json").read_text()) + uploaded = stub_paths() + for source in tsconfig["include"]: + stem = source.removesuffix(".ts") + for out in (f"{stem}.js", f"{stem}.d.ts"): + self.assertIn(out, uploaded, f"{out} is built by tsc but not uploaded to publish") + + def test_every_listed_tsc_file_is_uploaded(self) -> None: + package = json.loads((JS / "package.json").read_text()) + tsc_outputs = { + f"{source.removesuffix('.ts')}{ext}" + for source in json.loads((JS / "tsconfig.json").read_text())["include"] + for ext in (".js", ".d.ts") + } + uploaded = stub_paths() + for entry in package["files"]: + if entry in tsc_outputs: + self.assertIn(entry, uploaded, f"package.json lists {entry} but publish never receives it") + + def test_every_export_target_is_packaged(self) -> None: + package = json.loads((JS / "package.json").read_text()) + files = set(package["files"]) + for subpath, targets in package["exports"].items(): + for target in targets.values(): + self.assertIn(target.removeprefix("./"), files, f"export {subpath} -> {target} not in files") + + +if __name__ == "__main__": + unittest.main() diff --git a/site/src/content/apidocs/python.md b/site/src/content/apidocs/python.md index c50ac8bb1..104861bc2 100644 --- a/site/src/content/apidocs/python.md +++ b/site/src/content/apidocs/python.md @@ -1139,241 +1139,6 @@ Example: '/skills/my-skill\n' ``` -## ScriptedTool - -Compose Python callbacks as bash builtins for multi-tool orchestration. - -Each registered tool becomes a bash builtin command. An LLM (or user) -writes a single bash script that pipes, loops, and branches across tools. - -### Fields - -- **`name`** — `str` -- **`short_description`** — `str` -- **`version`** — `str` - -### Constructor - -```python -ScriptedTool(name: str, short_description: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None) -> None -``` - -Create a new ScriptedTool. - -**Parameters:** - -- **`name`** — Tool name (used as the LLM tool identifier). -- **`short_description`** — One-line description of the tool. -- **`max_commands`** — Limit total commands per execution. -- **`max_loop_iterations`** — Limit iterations per loop. - -Example: - -```python ->>> tool = ScriptedTool("data_pipeline", short_description="ETL tools") ->>> print(tool.name) -data_pipeline -``` - -### `add_tool` - -```python -ScriptedTool.add_tool(name: str, description: str, callback: Callable[[dict[str, Any], str | None], str], schema: dict[str, Any] | None = None) -> None -``` - -Register a Python callback as a bash builtin command. - -**Parameters:** - -- **`name`** — Command name (becomes a bash builtin). -- **`description`** — Human-readable description of the sub-tool. -- **`callback`** — ``(params_dict, stdin_or_none) -> output_string`` or an async callback that resolves to one. Async callbacks run on the caller's active asyncio loop for ``await execute()`` and on a private loop for ``execute_sync()``. -- **`schema`** — Optional JSON Schema for the tool's parameters. - -Example: - -```python ->>> tool = ScriptedTool("math") ->>> tool.add_tool( -... "add", "Add two numbers", -... callback=lambda p, s=None: str(int(p["a"]) + int(p["b"])) + "\n", -... schema={ -... "type": "object", -... "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, -... }, -... ) ->>> result = tool.execute_sync("add --a 2 --b 3") ->>> result.stdout.strip() -'5' -``` - -### `env` - -```python -ScriptedTool.env(key: str, value: str) -> None -``` - -Set an environment variable for subsequent executions. - -Example: - -```python ->>> tool = ScriptedTool("demo") ->>> tool.env("API_KEY", "secret-123") ->>> result = tool.execute_sync("echo $API_KEY") ->>> result.stdout.strip() -'secret-123' -``` - -### `execute` - -```python -ScriptedTool.execute(commands: str) -> ExecResult -``` - -Execute commands asynchronously. - -Async callbacks run on the caller's active asyncio loop. - -Example: - -```python ->>> tool = ScriptedTool("demo") ->>> tool.add_tool("hi", "Say hi", callback=lambda p, s=None: "hi\n") ->>> result = await tool.execute("hi") ->>> result.stdout.strip() -'hi' -``` - -### `execute_sync` - -```python -ScriptedTool.execute_sync(commands: str) -> ExecResult -``` - -Execute commands synchronously (blocking). - -Async callbacks run on a private loop here. - -Example: - -```python ->>> tool = ScriptedTool("demo") ->>> tool.add_tool("ping", "Ping", callback=lambda p, s=None: "pong\n") ->>> result = tool.execute_sync("ping") ->>> result.stdout.strip() -'pong' -``` - -### `tool_count` - -```python -ScriptedTool.tool_count() -> int -``` - -Return the number of registered sub-tools. - -Example: - -```python ->>> tool = ScriptedTool("demo") ->>> tool.tool_count() -0 ->>> tool.add_tool("a", "A", callback=lambda p, s=None: "") ->>> tool.tool_count() -1 -``` - -### `description` - -```python -ScriptedTool.description() -> str -``` - -Return the tool description for LLM consumption. - -Example: - -```python ->>> tool = ScriptedTool("api", short_description="API tools") ->>> desc = tool.description() ->>> len(desc) > 0 -True -``` - -### `help` - -```python -ScriptedTool.help() -> str -``` - -Return extended help text listing all registered sub-tools. - -Example: - -```python ->>> tool = ScriptedTool("api") ->>> tool.add_tool("fetch", "Fetch URL", callback=lambda p, s=None: "") ->>> "fetch" in tool.help() -True -``` - -### `system_prompt` - -```python -ScriptedTool.system_prompt() -> str -``` - -Return the system prompt for LLM agents. - -Includes descriptions of all registered sub-tools and usage examples. - -Example: - -```python ->>> tool = ScriptedTool("api") ->>> tool.add_tool("fetch", "Fetch URL", callback=lambda p, s=None: "") ->>> prompt = tool.system_prompt() ->>> "fetch" in prompt.lower() -True -``` - -### `input_schema` - -```python -ScriptedTool.input_schema() -> str -``` - -Return the JSON Schema for tool input. - -Example: - -```python ->>> import json ->>> tool = ScriptedTool("api") ->>> schema = json.loads(tool.input_schema()) ->>> "commands" in str(schema) -True -``` - -### `output_schema` - -```python -ScriptedTool.output_schema() -> str -``` - -Return the JSON Schema for tool output. - -Example: - -```python ->>> import json ->>> tool = ScriptedTool("api") ->>> schema = json.loads(tool.output_schema()) ->>> isinstance(schema, dict) -True -``` - ## FileSystem Direct access to Bashkit's virtual filesystem or a standalone mountable FS. @@ -1881,6 +1646,247 @@ True --- +# `bashkit.scripted` + +Multi-tool orchestration. Import with `from bashkit.scripted import ScriptedTool`. + +## ScriptedTool + +Compose Python callbacks as bash builtins for multi-tool orchestration. + +Each registered tool becomes a bash builtin command. An LLM (or user) +writes a single bash script that pipes, loops, and branches across tools. + +### Fields + +- **`name`** — `str` +- **`short_description`** — `str` +- **`version`** — `str` + +### Constructor + +```python +ScriptedTool(name: str, short_description: str | None = None, max_commands: int | None = None, max_loop_iterations: int | None = None) -> None +``` + +Create a new ScriptedTool. + +**Parameters:** + +- **`name`** — Tool name (used as the LLM tool identifier). +- **`short_description`** — One-line description of the tool. +- **`max_commands`** — Limit total commands per execution. +- **`max_loop_iterations`** — Limit iterations per loop. + +Example: + +```python +>>> tool = ScriptedTool("data_pipeline", short_description="ETL tools") +>>> print(tool.name) +data_pipeline +``` + +### `add_tool` + +```python +ScriptedTool.add_tool(name: str, description: str, callback: Callable[[dict[str, Any], str | None], str], schema: dict[str, Any] | None = None) -> None +``` + +Register a Python callback as a bash builtin command. + +**Parameters:** + +- **`name`** — Command name (becomes a bash builtin). +- **`description`** — Human-readable description of the sub-tool. +- **`callback`** — ``(params_dict, stdin_or_none) -> output_string`` or an async callback that resolves to one. Async callbacks run on the caller's active asyncio loop for ``await execute()`` and on a private loop for ``execute_sync()``. +- **`schema`** — Optional JSON Schema for the tool's parameters. + +Example: + +```python +>>> tool = ScriptedTool("math") +>>> tool.add_tool( +... "add", "Add two numbers", +... callback=lambda p, s=None: str(int(p["a"]) + int(p["b"])) + "\n", +... schema={ +... "type": "object", +... "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, +... }, +... ) +>>> result = tool.execute_sync("add --a 2 --b 3") +>>> result.stdout.strip() +'5' +``` + +### `env` + +```python +ScriptedTool.env(key: str, value: str) -> None +``` + +Set an environment variable for subsequent executions. + +Example: + +```python +>>> tool = ScriptedTool("demo") +>>> tool.env("API_KEY", "secret-123") +>>> result = tool.execute_sync("echo $API_KEY") +>>> result.stdout.strip() +'secret-123' +``` + +### `execute` + +```python +ScriptedTool.execute(commands: str) -> ExecResult +``` + +Execute commands asynchronously. + +Async callbacks run on the caller's active asyncio loop. + +Example: + +```python +>>> tool = ScriptedTool("demo") +>>> tool.add_tool("hi", "Say hi", callback=lambda p, s=None: "hi\n") +>>> result = await tool.execute("hi") +>>> result.stdout.strip() +'hi' +``` + +### `execute_sync` + +```python +ScriptedTool.execute_sync(commands: str) -> ExecResult +``` + +Execute commands synchronously (blocking). + +Async callbacks run on a private loop here. + +Example: + +```python +>>> tool = ScriptedTool("demo") +>>> tool.add_tool("ping", "Ping", callback=lambda p, s=None: "pong\n") +>>> result = tool.execute_sync("ping") +>>> result.stdout.strip() +'pong' +``` + +### `tool_count` + +```python +ScriptedTool.tool_count() -> int +``` + +Return the number of registered sub-tools. + +Example: + +```python +>>> tool = ScriptedTool("demo") +>>> tool.tool_count() +0 +>>> tool.add_tool("a", "A", callback=lambda p, s=None: "") +>>> tool.tool_count() +1 +``` + +### `description` + +```python +ScriptedTool.description() -> str +``` + +Return the tool description for LLM consumption. + +Example: + +```python +>>> tool = ScriptedTool("api", short_description="API tools") +>>> desc = tool.description() +>>> len(desc) > 0 +True +``` + +### `help` + +```python +ScriptedTool.help() -> str +``` + +Return extended help text listing all registered sub-tools. + +Example: + +```python +>>> tool = ScriptedTool("api") +>>> tool.add_tool("fetch", "Fetch URL", callback=lambda p, s=None: "") +>>> "fetch" in tool.help() +True +``` + +### `system_prompt` + +```python +ScriptedTool.system_prompt() -> str +``` + +Return the system prompt for LLM agents. + +Includes descriptions of all registered sub-tools and usage examples. + +Example: + +```python +>>> tool = ScriptedTool("api") +>>> tool.add_tool("fetch", "Fetch URL", callback=lambda p, s=None: "") +>>> prompt = tool.system_prompt() +>>> "fetch" in prompt.lower() +True +``` + +### `input_schema` + +```python +ScriptedTool.input_schema() -> str +``` + +Return the JSON Schema for tool input. + +Example: + +```python +>>> import json +>>> tool = ScriptedTool("api") +>>> schema = json.loads(tool.input_schema()) +>>> "commands" in str(schema) +True +``` + +### `output_schema` + +```python +ScriptedTool.output_schema() -> str +``` + +Return the JSON Schema for tool output. + +Example: + +```python +>>> import json +>>> tool = ScriptedTool("api") +>>> schema = json.loads(tool.output_schema()) +>>> isinstance(schema, dict) +True +``` + +--- + # Framework integrations ## `bashkit.langchain` diff --git a/site/src/content/apidocs/typescript.md b/site/src/content/apidocs/typescript.md index 66bbf49a5..6ea4c140e 100644 --- a/site/src/content/apidocs/typescript.md +++ b/site/src/content/apidocs/typescript.md @@ -862,162 +862,6 @@ BashTool.fromSnapshotKeyed(data: Uint8Array, key: Uint8Array, options?: BashOpti Create a new BashTool instance from a HMAC-protected snapshot. -## ScriptedTool - -Compose JS callbacks as bash builtins for multi-tool orchestration. - -Each registered tool becomes a bash builtin command. An LLM (or user) writes -a single bash script that pipes, loops, and branches across all tools. - -```typescript -import { ScriptedTool } from '@everruns/bashkit'; - -const tool = new ScriptedTool({ name: "api" }); -tool.addTool("greet", "Greet user", - (params) => `hello ${params.name ?? "world"}\n` -); -const result = tool.executeSync("greet --name Alice"); -console.log(result.stdout); // hello Alice\n -``` - -### Constructor - -```typescript -new ScriptedTool(options: ScriptedToolOptions): ScriptedTool -``` - -### `name` - -```typescript -name: string -``` - -Tool name. - -### `shortDescription` - -```typescript -shortDescription: string -``` - -Short description. - -### `version` - -```typescript -version: string -``` - -Tool version. - -### `addTool` - -```typescript -scriptedTool.addTool(name: string, description: string, callback: ToolCallback, schema?: Record): void -``` - -Register a tool command. - -### `description` - -```typescript -scriptedTool.description(): string -``` - -Token-efficient tool description. - -### `env` - -```typescript -scriptedTool.env(key: string, value: string): void -``` - -Add an environment variable visible inside scripts. - -### `execute` - -```typescript -scriptedTool.execute(commands: string): Promise -``` - -Execute a bash script asynchronously, returning a Promise. - -This is the recommended execution method for ScriptedTool since -tool callbacks require the Node.js event loop to be running. - -### `executeOrThrow` - -```typescript -scriptedTool.executeOrThrow(commands: string): Promise -``` - -Execute asynchronously. Throws `BashError` on non-zero exit. - -### `executeSync` - -```typescript -scriptedTool.executeSync(commands: string): ExecResult -``` - -Execute a bash script synchronously. - -Note: ScriptedTool callbacks run asynchronously via Node's event loop. -If a registered tool is invoked, this method returns a non-zero result -instead of queueing a callback that would deadlock. Use `execute()` -(async) for scripts that call registered tools. Only use this for scripts -that don't invoke any registered tools (e.g., pure bash). - -### `executeSyncOrThrow` - -```typescript -scriptedTool.executeSyncOrThrow(commands: string): ExecResult -``` - -Execute synchronously. Throws `BashError` on non-zero exit. - -Same caveats as `executeSync()` — throws when a registered tool would -require the blocked Node event loop. Use `executeOrThrow()` instead. - -### `help` - -```typescript -scriptedTool.help(): string -``` - -Markdown help document. - -### `inputSchema` - -```typescript -scriptedTool.inputSchema(): string -``` - -JSON input schema as string. - -### `outputSchema` - -```typescript -scriptedTool.outputSchema(): string -``` - -JSON output schema as string. - -### `systemPrompt` - -```typescript -scriptedTool.systemPrompt(): string -``` - -Compact system prompt for orchestration. - -### `toolCount` - -```typescript -scriptedTool.toolCount(): number -``` - -Number of registered tools. - ## FileSystem ### Constructor @@ -1634,17 +1478,6 @@ the builtin registry, the network allowlist, and the mount policy. Node budget hit — `commands` and `redirects` are incomplete. -## ScriptedToolOptions - -Options for creating a ScriptedTool instance. - -### Fields - -- **`maxCommands?`** — `number` -- **`maxLoopIterations?`** — `number` -- **`name`** — `string` -- **`shortDescription?`** — `string` - ## ShellState Lightweight snapshot of shell state for inspection (prompt rendering, @@ -1749,7 +1582,185 @@ type FileValue = string | () => string | () => Promise type OnOutput = (chunk: OutputChunk) => void ``` -## ToolCallback +--- + +# Subpath modules + +## `@everruns/bashkit/scripted` + +Multi-tool orchestration for Bashkit (`ScriptedTool`). + +Register JS callbacks as bash builtins; one bash script composes them with +pipes, loops, and `jq` in a logic-only shell. + +### ScriptedTool + +Compose JS callbacks as bash builtins for multi-tool orchestration. + +Each registered tool becomes a bash builtin command. An LLM (or user) writes +a single bash script that pipes, loops, and branches across all tools. + +```typescript +import { ScriptedTool } from '@everruns/bashkit/scripted'; + +const tool = new ScriptedTool({ name: "api" }); +tool.addTool("greet", "Greet user", + (params) => `hello ${params.name ?? "world"}\n` +); +const result = tool.executeSync("greet --name Alice"); +console.log(result.stdout); // hello Alice\n +``` + +### Constructor + +```typescript +new ScriptedTool(options: ScriptedToolOptions): ScriptedTool +``` + +### `name` + +```typescript +name: string +``` + +Tool name. + +### `shortDescription` + +```typescript +shortDescription: string +``` + +Short description. + +### `version` + +```typescript +version: string +``` + +Tool version. + +### `addTool` + +```typescript +scriptedTool.addTool(name: string, description: string, callback: ToolCallback, schema?: Record): void +``` + +Register a tool command. + +### `description` + +```typescript +scriptedTool.description(): string +``` + +Token-efficient tool description. + +### `env` + +```typescript +scriptedTool.env(key: string, value: string): void +``` + +Add an environment variable visible inside scripts. + +### `execute` + +```typescript +scriptedTool.execute(commands: string): Promise +``` + +Execute a bash script asynchronously, returning a Promise. + +This is the recommended execution method for ScriptedTool since +tool callbacks require the Node.js event loop to be running. + +### `executeOrThrow` + +```typescript +scriptedTool.executeOrThrow(commands: string): Promise +``` + +Execute asynchronously. Throws `BashError` on non-zero exit. + +### `executeSync` + +```typescript +scriptedTool.executeSync(commands: string): ExecResult +``` + +Execute a bash script synchronously. + +Note: ScriptedTool callbacks run asynchronously via Node's event loop. +If a registered tool is invoked, this method returns a non-zero result +instead of queueing a callback that would deadlock. Use `execute()` +(async) for scripts that call registered tools. Only use this for scripts +that don't invoke any registered tools (e.g., pure bash). + +### `executeSyncOrThrow` + +```typescript +scriptedTool.executeSyncOrThrow(commands: string): ExecResult +``` + +Execute synchronously. Throws `BashError` on non-zero exit. + +Same caveats as `executeSync()` — throws when a registered tool would +require the blocked Node event loop. Use `executeOrThrow()` instead. + +### `help` + +```typescript +scriptedTool.help(): string +``` + +Markdown help document. + +### `inputSchema` + +```typescript +scriptedTool.inputSchema(): string +``` + +JSON input schema as string. + +### `outputSchema` + +```typescript +scriptedTool.outputSchema(): string +``` + +JSON output schema as string. + +### `systemPrompt` + +```typescript +scriptedTool.systemPrompt(): string +``` + +Compact system prompt for orchestration. + +### `toolCount` + +```typescript +scriptedTool.toolCount(): number +``` + +Number of registered tools. + +### ScriptedToolOptions + +Options for creating a ScriptedTool instance. + +### Fields + +- **`maxCommands?`** — `number` +- **`maxLoopIterations?`** — `number` +- **`name`** — `string` +- **`shortDescription?`** — `string` + +### ToolCallback Callback type for ScriptedTool tool commands. @@ -1760,10 +1771,6 @@ Must return a string. type ToolCallback = (params: Record, stdin: string | null) => string ``` ---- - -# Framework integrations - ## `@everruns/bashkit/langchain` LangChain.js integration for Bashkit. @@ -1779,7 +1786,7 @@ const tool = createBashTool(); const result = await tool.invoke({ commands: "echo hello" }); // Scripted tool -import { ScriptedTool } from '@everruns/bashkit'; +import { ScriptedTool } from '@everruns/bashkit/scripted'; const st = new ScriptedTool({ name: "api" }); st.addTool("greet", "Greet user", (p) => `hello ${p.name}\n`); const langchainTool = createScriptedTool(st); @@ -1815,7 +1822,7 @@ Create a LangChain-compatible tool from a configured ScriptedTool. The ScriptedTool should already have tools registered via `addTool()`. ```typescript -import { ScriptedTool } from '@everruns/bashkit'; +import { ScriptedTool } from '@everruns/bashkit/scripted'; import { createScriptedTool } from '@everruns/bashkit/langchain'; const st = new ScriptedTool({ name: "api" });