Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions crates/bashkit-js/__test__/ai-adapters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,82 @@ test("openai: files created via handler are readable via bash.readFile", async (
});
t.is(adapter.bash.readFile("/y.txt"), "created");
});

// ============================================================================
// Issue #1185: Framework timeout propagation
// ============================================================================

// --- Timeout via timeoutMs option -------------------------------------------

test("anthropic: timeoutMs option propagates to interpreter", async (t) => {
const adapter = anthropicBashTool({ timeoutMs: 500 });
const result = await adapter.handler({
type: "tool_use",
id: "t-timeout",
name: "bash",
input: { commands: "i=0; while true; do i=$((i+1)); done" },
});
// Timed-out execution should produce a non-success result
t.true(
result.is_error === true ||
result.content.includes("124") ||
result.content.includes("timeout"),
);
});

test("openai: timeoutMs option propagates to interpreter", async (t) => {
const adapter = openAiBashTool({ timeoutMs: 500 });
const result = await adapter.handler({
id: "c-timeout",
type: "function",
function: {
name: "bash",
arguments: JSON.stringify({
commands: "i=0; while true; do i=$((i+1)); done",
}),
},
});
// Timed-out execution should produce an error or exit 124
t.true(
result.content.includes("124") ||
result.content.includes("timeout") ||
result.content.includes("Exit code"),
);
});

// --- AbortSignal cancellation -----------------------------------------------

test("anthropic: handler respects pre-aborted signal", async (t) => {
const adapter = anthropicBashTool();
const controller = new AbortController();
controller.abort();
const result = await adapter.handler(
{
type: "tool_use",
id: "t-abort",
name: "bash",
input: { commands: "echo should-not-run" },
},
{ signal: controller.signal },
);
t.is(result.content, "Execution cancelled");
t.true(result.is_error);
});

test("openai: handler respects pre-aborted signal", async (t) => {
const adapter = openAiBashTool();
const controller = new AbortController();
controller.abort();
const result = await adapter.handler(
{
id: "c-abort",
type: "function",
function: {
name: "bash",
arguments: JSON.stringify({ commands: "echo should-not-run" }),
},
},
{ signal: controller.signal },
);
t.is(result.content, "Execution cancelled");
});
59 changes: 56 additions & 3 deletions crates/bashkit-js/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ import type { BashOptions, ExecResult } from "./wrapper.js";
export interface BashToolOptions extends Omit<BashOptions, "files"> {
/** Pre-populate VFS files. Keys are absolute paths, values are file contents. */
files?: Record<string, string>;
/**
* Execution timeout in milliseconds.
*
* When set, this is passed to the underlying BashTool as `timeoutMs`.
* Commands exceeding this duration are aborted with exit code 124.
* Framework-level timeouts can be propagated here to ensure bashkit
* stops execution when the framework cancels a tool call.
*/
timeoutMs?: number;
}

/** Anthropic tool definition (matches the `tools` array in messages.create). */
Expand Down Expand Up @@ -67,14 +76,33 @@ export interface ToolResult {
is_error?: boolean;
}

/** Options for handler invocation. */
export interface HandlerOptions {
/** AbortSignal to cancel execution when the framework aborts the tool call. */
signal?: AbortSignal;
}

/** Return value of `bashTool()`. */
export interface BashToolAdapter {
/** System prompt describing bash capabilities and constraints. */
system: string;
/** Tool definitions for Anthropic's messages.create() API. */
tools: AnthropicTool[];
/** Handler that executes a tool_use block and returns a tool_result. */
handler: (toolUse: ToolUseBlock) => Promise<ToolResult>;
/**
* Handler that executes a tool_use block and returns a tool_result.
*
* Pass an AbortSignal via the options parameter to cancel execution
* when the framework aborts the tool call:
*
* ```typescript
* const controller = new AbortController();
* const result = await bash.handler(block, { signal: controller.signal });
* ```
*/
handler: (
toolUse: ToolUseBlock,
options?: HandlerOptions,
) => Promise<ToolResult>;
/** The underlying BashTool instance for direct access. */
bash: BashTool;
}
Expand Down Expand Up @@ -147,7 +175,10 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
},
];

const handler = async (toolUse: ToolUseBlock): Promise<ToolResult> => {
const handler = async (
toolUse: ToolUseBlock,
handlerOptions?: HandlerOptions,
): Promise<ToolResult> => {
const commands = (toolUse.input as { commands?: string }).commands;
if (!commands) {
return {
Expand All @@ -158,6 +189,24 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
};
}

// Wire up AbortSignal to cancel bashkit execution when the
// framework (or caller) aborts the tool call.
const signal = handlerOptions?.signal;
if (signal?.aborted) {
return {
type: "tool_result",
tool_use_id: toolUse.id,
content: "Execution cancelled",
is_error: true,
};
}

let onAbort: (() => void) | undefined;
if (signal) {
onAbort = () => bash.cancel();
signal.addEventListener("abort", onAbort, { once: true });
}

try {
const result = await bash.execute(commands);
return {
Expand All @@ -173,6 +222,10 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
content: `Execution error: ${err instanceof Error ? err.message : String(err)}`,
is_error: true,
};
} finally {
if (signal && onAbort) {
signal.removeEventListener("abort", onAbort);
}
}
};

Expand Down
58 changes: 55 additions & 3 deletions crates/bashkit-js/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ import type { BashOptions, ExecResult } from "./wrapper.js";
export interface BashToolOptions extends Omit<BashOptions, "files"> {
/** Pre-populate VFS files. Keys are absolute paths, values are file contents. */
files?: Record<string, string>;
/**
* Execution timeout in milliseconds.
*
* When set, this is passed to the underlying BashTool as `timeoutMs`.
* Commands exceeding this duration are aborted with exit code 124.
* Framework-level timeouts can be propagated here to ensure bashkit
* stops execution when the framework cancels a tool call.
*/
timeoutMs?: number;
}

/** OpenAI function tool definition (matches the `tools` array in chat.completions.create). */
Expand Down Expand Up @@ -70,14 +79,33 @@ export interface ToolResult {
content: string;
}

/** Options for handler invocation. */
export interface HandlerOptions {
/** AbortSignal to cancel execution when the framework aborts the tool call. */
signal?: AbortSignal;
}

/** Return value of `bashTool()`. */
export interface BashToolAdapter {
/** System prompt describing bash capabilities and constraints. */
system: string;
/** Tool definitions for OpenAI's chat.completions.create() API. */
tools: OpenAITool[];
/** Handler that executes a tool_call and returns a tool message. */
handler: (toolCall: OpenAIToolCall) => Promise<ToolResult>;
/**
* Handler that executes a tool_call and returns a tool message.
*
* Pass an AbortSignal via the options parameter to cancel execution
* when the framework aborts the tool call:
*
* ```typescript
* const controller = new AbortController();
* const result = await bash.handler(call, { signal: controller.signal });
* ```
*/
handler: (
toolCall: OpenAIToolCall,
options?: HandlerOptions,
) => Promise<ToolResult>;
/** The underlying BashTool instance for direct access. */
bash: BashTool;
}
Expand Down Expand Up @@ -153,7 +181,10 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
},
];

const handler = async (toolCall: OpenAIToolCall): Promise<ToolResult> => {
const handler = async (
toolCall: OpenAIToolCall,
handlerOptions?: HandlerOptions,
): Promise<ToolResult> => {
let commands: string;
try {
const args = JSON.parse(toolCall.function.arguments);
Expand All @@ -174,6 +205,23 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
};
}

// Wire up AbortSignal to cancel bashkit execution when the
// framework (or caller) aborts the tool call.
const signal = handlerOptions?.signal;
if (signal?.aborted) {
return {
role: "tool",
tool_call_id: toolCall.id,
content: "Execution cancelled",
};
}

let onAbort: (() => void) | undefined;
if (signal) {
onAbort = () => bash.cancel();
signal.addEventListener("abort", onAbort, { once: true });
}

try {
const result = await bash.execute(commands);
return {
Expand All @@ -187,6 +235,10 @@ export function bashTool(options?: BashToolOptions): BashToolAdapter {
tool_call_id: toolCall.id,
content: `Execution error: ${err instanceof Error ? err.message : String(err)}`,
};
} finally {
if (signal && onAbort) {
signal.removeEventListener("abort", onAbort);
}
}
};

Expand Down
13 changes: 13 additions & 0 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export interface BashOptions {
* ```
*/
maxMemory?: number;
/**
* Execution timeout in milliseconds.
*
* When set, commands that exceed this duration are aborted with
* exit code 124 (matching the bash `timeout` convention).
*
* @example
* ```typescript
* const bash = new Bash({ timeoutMs: 30000 }); // 30 seconds
* ```
*/
timeoutMs?: number;
/**
* Files to mount in the virtual filesystem.
* Keys are absolute paths, values are content strings or lazy providers.
Expand Down Expand Up @@ -148,6 +160,7 @@ function toNativeOptions(
maxCommands: options?.maxCommands,
maxLoopIterations: options?.maxLoopIterations,
maxMemory: options?.maxMemory,
timeoutMs: options?.timeoutMs,
files: resolvedFiles,
mounts: options?.mounts?.map((m) => ({
hostPath: m.root,
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Bash:
max_commands: int | None = None,
max_loop_iterations: int | None = None,
max_memory: int | None = None,
timeout_seconds: float | None = None,
python: bool = False,
external_functions: list[str] | None = None,
external_handler: ExternalHandler | None = None,
Expand Down Expand Up @@ -120,6 +121,7 @@ class BashTool:
max_commands: int | None = None,
max_loop_iterations: int | None = None,
max_memory: int | None = None,
timeout_seconds: float | None = None,
files: dict[str, str] | None = None,
mounts: list[dict[str, Any]] | None = None,
) -> None: ...
Expand Down
5 changes: 5 additions & 0 deletions crates/bashkit-python/bashkit/deepagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def __init__(
hostname: str | None = None,
max_commands: int | None = None,
max_loop_iterations: int | None = None,
timeout_seconds: float | None = None,
):
"""Initialize middleware.

Expand All @@ -113,6 +114,7 @@ def __init__(
hostname: Hostname for new BashTool (ignored if bash_tool provided)
max_commands: Max commands (ignored if bash_tool provided)
max_loop_iterations: Max iterations (ignored if bash_tool provided)
timeout_seconds: Execution timeout in seconds (ignored if bash_tool provided)
"""
if bash_tool is not None:
self._bash = bash_tool
Expand All @@ -123,6 +125,7 @@ def __init__(
hostname=hostname,
max_commands=max_commands,
max_loop_iterations=max_loop_iterations,
timeout_seconds=timeout_seconds,
)
self._owns_bash = True

Expand Down Expand Up @@ -168,12 +171,14 @@ def __init__(
hostname: str | None = None,
max_commands: int | None = None,
max_loop_iterations: int | None = None,
timeout_seconds: float | None = None,
):
self._bash = NativeBashTool(
username=username,
hostname=hostname,
max_commands=max_commands,
max_loop_iterations=max_loop_iterations,
timeout_seconds=timeout_seconds,
)
self._id = f"bashkit-{uuid.uuid4().hex[:8]}"

Expand Down
Loading
Loading