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
6 changes: 4 additions & 2 deletions .github/workflows/integration-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: false
shared-key: "integration-tests"
# Changed cache key to invalidate corrupted v8 cache from adding just-bash/bundle.js
shared-key: "integration-tests-v2"

# Build pctx binary in release mode
- name: Build pctx
Expand Down Expand Up @@ -87,7 +88,8 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
cache-on-failure: false
shared-key: "integration-tests"
# Changed cache key to invalidate corrupted v8 cache from adding just-bash/bundle.js
shared-key: "integration-tests-v2"

# Build pctx binary in release mode (should hit cache from cli-tests)
- name: Build pctx
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Python `@tool` decorator now parses docstrings (Google, NumPy, reStructuredText, and Epydoc formats) to extract parameter descriptions, return value descriptions, and detailed function descriptions into tool schemas
- Make code mode config and all tools / descriptions easily configurable from python client
- Add just-bash and new execute_bash tool to explore filesystem of the generated sdk

### Changed

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help release publish-crates docs test-python test-cli
.PHONY: help release publish-crates docs test-python test-python-integration format-python test-cli

# Default target - show help when running just 'make'
.DEFAULT_GOAL := help
Expand All @@ -10,6 +10,7 @@ help:
@echo " make docs - Generate CLI and Python documentation"
@echo " make test-python - Run Python client tests"
@echo " make test-python-integration - Run Python client tests with integration testing"
@echo " make format-python - Format and lint Python code with ruff"
@echo " make test-cli - Run CLI integration tests (pctx mcp start)"
@echo " make release - Interactive release script (bump version, update changelog)"
@echo " make publish-crates - Publish pctx_code_mode + dependencies to crates.io (runs locally)"
Expand Down
1 change: 1 addition & 0 deletions crates/pctx_code_execution_runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ deno_error = { workspace = true }
url = { workspace = true }
rmcp = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true, features = ["time"] }

[build-dependencies]
pctx_config = { version = "^0.1.3", path = "../pctx_config" }
Expand Down
20 changes: 18 additions & 2 deletions crates/pctx_code_execution_runtime/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
//! with all its JavaScript code pre-compiled. This snapshot can be loaded by
//! `pctx_executor` for faster startup times.

// Allow long const eval for large JavaScript bundles
#![allow(long_running_const_eval)]

use std::env;
use std::path::PathBuf;

Expand Down Expand Up @@ -36,6 +39,11 @@ async fn op_invoke_callback(
serde_json::Value::Null
}

/// Sleep (stub for timers)
#[deno_core::op2(async)]
#[allow(clippy::unused_async)]
async fn op_sleep(#[bigint] _delay_ms: u64) {}

// We need to define the extension here as well for snapshot creation
// The esm_entry_point tells deno_core to execute this module during snapshot creation
extension!(
Expand All @@ -44,14 +52,22 @@ extension!(
// Op declarations - these will be registered but not executed during snapshot
op_call_mcp_tool,
op_invoke_callback,
op_sleep,
],
esm_entry_point = "ext:pctx_runtime_snapshot/runtime.js",
esm = [ dir "src", "runtime.js" ],
esm = [
dir "src",
"timers.js",
"runtime.js",
"just-bash/bundle.js",
"just-bash/node_zlib_stub.js",
],
);

fn main() {
// Tell cargo to rerun this build script if runtime.js or build.rs changes
// Tell cargo to rerun this build script if runtime.js, just-bash/bundle.js, or build.rs changes
println!("cargo:rerun-if-changed=src/runtime.js");
println!("cargo:rerun-if-changed=src/just-bash/bundle.js");
println!("cargo:rerun-if-changed=build.rs");

// Get the output directory
Expand Down
9 changes: 9 additions & 0 deletions crates/pctx_code_execution_runtime/src/just-bash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Current `just-bash` version: `2.6.0`

1. deno bundle --platform browser --minify --external "@mongodb-js/zstd" --external "node-liblzma" entry.ts -o bundle.js

2. replace node:zlib imports with local stubs

```bash
sed -i '' 's|from"node:zlib"|from"./node_zlib_stub.js"|g' bundle.js && grep -c "node:zlib" bundle.js || echo "0 instances remaining"
```
1,538 changes: 1,538 additions & 0 deletions crates/pctx_code_execution_runtime/src/just-bash/bundle.js

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions crates/pctx_code_execution_runtime/src/just-bash/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Entry point for bundling just-bash into the pctx runtime
// This file is used by `deno bundle` to create a single-file bundle

import { Bash } from "npm:just-bash";

// Re-export the Bash class for use in the runtime
export { Bash };
27 changes: 27 additions & 0 deletions crates/pctx_code_execution_runtime/src/just-bash/node_zlib_stub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Stub for node:zlib - compression not supported in this runtime
export function gunzipSync() {
throw new Error("Compression not supported: gunzipSync is not available in this runtime");
}

export function gzipSync() {
throw new Error("Compression not supported: gzipSync is not available in this runtime");
}

export const constants = {
Z_NO_FLUSH: 0,
Z_PARTIAL_FLUSH: 1,
Z_SYNC_FLUSH: 2,
Z_FULL_FLUSH: 3,
Z_FINISH: 4,
Z_BLOCK: 5,
Z_TREES: 6,
Z_OK: 0,
Z_STREAM_END: 1,
Z_NEED_DICT: 2,
Z_ERRNO: -1,
Z_STREAM_ERROR: -2,
Z_DATA_ERROR: -3,
Z_MEM_ERROR: -4,
Z_BUF_ERROR: -5,
Z_VERSION_ERROR: -6,
};
13 changes: 12 additions & 1 deletion crates/pctx_code_execution_runtime/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Allow long const eval for large JavaScript bundles
#![allow(long_running_const_eval)]

//! # PCTX Runtime
//!
//! A Deno extension providing MCP (Model Context Protocol) client functionality and console output capturing.
Expand Down Expand Up @@ -60,6 +63,7 @@ mod error;
mod js_error_impl;
pub mod mcp_ops;
mod mcp_registry;
mod timer_ops;

pub use callback_registry::{CallbackFn, CallbackRegistry};
pub use mcp_registry::MCPRegistry;
Expand All @@ -85,9 +89,16 @@ deno_core::extension!(
ops = [
mcp_ops::op_call_mcp_tool,
callback_ops::op_invoke_callback,
timer_ops::op_sleep,
],
esm_entry_point = "ext:pctx_runtime_snapshot/runtime.js",
esm = [ dir "src", "runtime.js" ],
esm = [
dir "src",
"timers.js",
"runtime.js",
"just-bash/bundle.js",
"just-bash/node_zlib_stub.js",
],
options = {
registry: MCPRegistry,
callback_registry: CallbackRegistry,
Expand Down
7 changes: 7 additions & 0 deletions crates/pctx_code_execution_runtime/src/runtime.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// PCTX Runtime - MCP Client and Console Capturing

// Import timers first - sets up setTimeout/setInterval globals
import "ext:pctx_runtime_snapshot/timers.js";

// Now import just-bash (can use setTimeout)
import { Bash } from "ext:pctx_runtime_snapshot/just-bash/bundle.js";

const core = Deno.core;
const ops = core.ops;

Expand Down Expand Up @@ -101,3 +107,4 @@ export async function invokeCallback(call) {
// Make APIs available globally for convenience (matching original behavior)
globalThis.callMCPTool = callMCPTool;
globalThis.invokeCallback = invokeCallback;
globalThis.justBash = Bash; // lowercase to avoid any clashes with namespaces
15 changes: 15 additions & 0 deletions crates/pctx_code_execution_runtime/src/timer_ops.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Deno ops for timer functionality (setTimeout, setInterval)
//!
//! Provides a simple async sleep op that JavaScript uses to implement timers.

use deno_core::op2;
use std::time::Duration;

/// Sleep for the specified number of milliseconds
///
/// This async op is used by the JavaScript layer to implement setTimeout and setInterval.
/// It simply waits for the specified duration before resolving.
#[op2(async)]
pub(crate) async fn op_sleep(#[bigint] delay_ms: u64) {
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
151 changes: 151 additions & 0 deletions crates/pctx_code_execution_runtime/src/timers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Polyfills - loaded as entry point before other modules
// Sets up global APIs that just-bash and other code may need

const ops = Deno.core.ops;

// ============================================================================
// Process (Node.js compatibility)
// ============================================================================

globalThis.process = {
env: {},
cwd: () => "/",
platform: "linux",
version: "v20.0.0",
versions: { node: "20.0.0" },
argv: [],
stdout: { write: () => {} },
stderr: { write: () => {} },
};

// ============================================================================
// TextEncoder / TextDecoder (Web APIs)
// ============================================================================

globalThis.TextEncoder = class TextEncoder {
encode(str) {
const utf8 = [];
for (let i = 0; i < str.length; i++) {
let charCode = str.charCodeAt(i);
if (charCode < 0x80) {
utf8.push(charCode);
} else if (charCode < 0x800) {
utf8.push(0xc0 | (charCode >> 6), 0x80 | (charCode & 0x3f));
} else if (charCode < 0xd800 || charCode >= 0xe000) {
utf8.push(
0xe0 | (charCode >> 12),
0x80 | ((charCode >> 6) & 0x3f),
0x80 | (charCode & 0x3f)
);
} else {
// Surrogate pair
i++;
charCode = 0x10000 + (((charCode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
utf8.push(
0xf0 | (charCode >> 18),
0x80 | ((charCode >> 12) & 0x3f),
0x80 | ((charCode >> 6) & 0x3f),
0x80 | (charCode & 0x3f)
);
}
}
return new Uint8Array(utf8);
}
};

globalThis.TextDecoder = class TextDecoder {
decode(bytes) {
if (!bytes) return "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
let result = "";
let i = 0;
while (i < arr.length) {
const byte1 = arr[i++];
if (byte1 < 0x80) {
result += String.fromCharCode(byte1);
} else if (byte1 < 0xe0) {
const byte2 = arr[i++];
result += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f));
} else if (byte1 < 0xf0) {
const byte2 = arr[i++];
const byte3 = arr[i++];
result += String.fromCharCode(
((byte1 & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)
);
} else {
const byte2 = arr[i++];
const byte3 = arr[i++];
const byte4 = arr[i++];
const codePoint =
((byte1 & 0x07) << 18) |
((byte2 & 0x3f) << 12) |
((byte3 & 0x3f) << 6) |
(byte4 & 0x3f);
// Convert to surrogate pair
const surrogate = codePoint - 0x10000;
result += String.fromCharCode(
0xd800 + (surrogate >> 10),
0xdc00 + (surrogate & 0x3ff)
);
}
}
return result;
}
};

// ============================================================================
// Timers (setTimeout, setInterval, etc.)
// ============================================================================

let nextTimerId = 1;
const activeTimers = new Map();

globalThis.setTimeout = (callback, delay = 0, ...args) => {
const id = nextTimerId++;
activeTimers.set(id, { type: "timeout", cancelled: false });

ops.op_sleep(BigInt(Math.max(0, delay))).then(() => {
const timer = activeTimers.get(id);
if (timer && !timer.cancelled) {
activeTimers.delete(id);
callback(...args);
}
});

return id;
};

globalThis.clearTimeout = (id) => {
const timer = activeTimers.get(id);
if (timer) {
timer.cancelled = true;
activeTimers.delete(id);
}
};

globalThis.setInterval = (callback, delay = 0, ...args) => {
const id = nextTimerId++;
activeTimers.set(id, { type: "interval", cancelled: false });

const runInterval = async () => {
while (true) {
await ops.op_sleep(BigInt(Math.max(0, delay)));
const timer = activeTimers.get(id);
if (!timer || timer.cancelled) {
break;
}
callback(...args);
}
};

runInterval();
return id;
};

globalThis.clearInterval = (id) => {
const timer = activeTimers.get(id);
if (timer) {
timer.cancelled = true;
activeTimers.delete(id);
}
};
Loading