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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Configurable per-tool-call timeouts on `execute_typescript`, replacing a hardcoded 30s: `tool_timeout_secs` applies to every tool the execution calls, `tool_timeout_overrides` overrides individual tools by id (`namespace__name`). Both optional (default 30s), clamped to 1–600s, and available in the Python client. Bounds one call, not the whole execution.

### Changed

- Tool call timeout errors now name the tool and limit (``Tool `test_math__add` timed out after 30s``, previously `Execution timeout`).

### Fixed

## [v0.7.2] - 2026-07-16
Expand Down
49 changes: 49 additions & 0 deletions crates/pctx_session_server/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::{collections::HashMap, time::Duration};

use axum::{Json, http::StatusCode, response::IntoResponse};
use pctx_code_mode::{config, model::ExecuteTypescriptOutput};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -131,11 +133,58 @@ pub struct ExecuteToolParams {
pub args: Option<serde_json::Value>,
}

impl ExecuteToolParams {
/// Registry id of the tool (`namespace__name`), matching
/// [`pctx_code_mode::model::CallbackConfig::id`].
pub fn tool_id(&self) -> String {
match &self.namespace {
Some(ns) => format!("{ns}__{}", self.name),
None => self.name.clone(),
}
}
}

/// Timeout applied to a single tool call when the request specifies none.
pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 30;
/// Upper bound on a client-supplied tool call timeout.
///
/// Each in-flight call holds a blocking thread, so an unbounded value lets a
/// client pin one indefinitely.
pub const MAX_TOOL_TIMEOUT_SECS: u64 = 600;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteTypescriptParams {
pub code: String,
#[serde(default)]
pub disclosure: config::ToolDisclosure,
/// Timeout applied to every tool call made by this execution, in seconds.
///
/// Defaults to [`DEFAULT_TOOL_TIMEOUT_SECS`]. This bounds a single call, not
/// the execution as a whole: code making N sequential calls can run for N
/// times this value.
#[serde(default)]
pub tool_timeout_secs: Option<u64>,
/// Per-tool overrides of `tool_timeout_secs`, keyed by tool id
/// (`namespace__name`, or just `name` when the tool has no namespace).
///
/// Ids with no registered tool are ignored.
#[serde(default)]
pub tool_timeout_overrides: HashMap<String, u64>,
}

impl ExecuteTypescriptParams {
/// Resolves the timeout for a tool: override, then request default, then
/// [`DEFAULT_TOOL_TIMEOUT_SECS`] — clamped to [`MAX_TOOL_TIMEOUT_SECS`].
pub fn tool_timeout(&self, tool_id: &str) -> Duration {
let secs = self
.tool_timeout_overrides
.get(tool_id)
.copied()
.or(self.tool_timeout_secs)
.unwrap_or(DEFAULT_TOOL_TIMEOUT_SECS);

Duration::from_secs(secs.clamp(1, MAX_TOOL_TIMEOUT_SECS))
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
17 changes: 12 additions & 5 deletions crates/pctx_session_server/src/state/ws_manager.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{collections::HashMap, sync::Arc};
use std::{collections::HashMap, sync::Arc, time::Duration};

use rmcp::model::RequestId;
use tokio::sync::{RwLock, mpsc as tokio_mpsc};
Expand All @@ -15,8 +15,10 @@ pub enum ExecuteCallbackError {
ExecutionFailed(rmcp::model::ErrorData),
#[error("Response channel closed")]
ChannelClosed,
#[error("Execution timeout")]
Timeout,
/// `tool` is the registry id (`namespace__name`), matching the key callers
/// use in `tool_timeout_overrides`.
#[error("Tool `{tool}` timed out after {}s", .timeout.as_secs())]
Timeout { tool: String, timeout: Duration },
}

#[derive(Default)]
Expand Down Expand Up @@ -144,8 +146,10 @@ impl WsSession {
pub async fn execute_callback(
&self,
params: ExecuteToolParams,
timeout: Duration,
) -> Result<ExecuteToolResult, ExecuteCallbackError> {
let req_id = RequestId::String(Uuid::new_v4().to_string().into());
let tool_id = params.tool_id();
// Create std::sync::mpsc channel for response
let (response_tx, response_rx) = std::sync::mpsc::channel();

Expand All @@ -165,7 +169,7 @@ impl WsSession {

// Wait for response with timeout
let result = tokio::time::timeout(
tokio::time::Duration::from_secs(30),
timeout,
tokio::task::spawn_blocking(move || response_rx.recv()),
)
.await;
Expand All @@ -178,7 +182,10 @@ impl WsSession {
Ok(Ok(Ok(Err(error)))) => Err(ExecuteCallbackError::ExecutionFailed(error)),
Ok(Ok(Err(_))) => Err(ExecuteCallbackError::ChannelClosed),
Ok(Err(_)) => Err(ExecuteCallbackError::ChannelClosed),
Err(_) => Err(ExecuteCallbackError::Timeout),
Err(_) => Err(ExecuteCallbackError::Timeout {
tool: tool_id,
timeout,
}),
}
}

Expand Down
14 changes: 9 additions & 5 deletions crates/pctx_session_server/src/websocket/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ async fn handle_execute_code_request<B: PctxSessionBackend>(
for callback_cfg in code_mode.callbacks() {
let ws_session_lock_clone = ws_session_lock.clone();
let cfg = callback_cfg.clone();
let timeout = params.tool_timeout(&callback_cfg.id());

let callback: CallbackFn = Arc::new(move |args: Option<serde_json::Value>| {
let cfg = cfg.clone();
Expand All @@ -235,11 +236,14 @@ async fn handle_execute_code_request<B: PctxSessionBackend>(
let ws_session = ws_session_lock_clone.read().await;

let callback_res = ws_session
.execute_callback(ExecuteToolParams {
namespace: cfg.namespace,
name: cfg.name,
args,
})
.execute_callback(
ExecuteToolParams {
namespace: cfg.namespace,
name: cfg.name,
args,
},
timeout,
)
.await
.map_err(|e| e.to_string())?;

Expand Down
101 changes: 101 additions & 0 deletions crates/pctx_session_server/tests/executions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -863,3 +863,104 @@ async fn test_bash_exploration_then_typescript_execution() {
})
);
}

/// A per-tool override takes precedence over the request-wide default: `add` is
/// held to 1s while every other tool gets 600s, and the client never answers.
#[tokio::test]
#[serial]
async fn test_exec_callback_timeout_override() {
let (session_id, server, _) = create_test_server_with_session().await;

let test_tools: Vec<CallbackConfig> = callback_tools().into_iter().map(|(c, _)| c).collect();
let register_res = server
.post("/register/tools")
.add_header(CODE_MODE_SESSION_HEADER, session_id.to_string())
.json(&json!({ "tools": test_tools }))
.await;
register_res.assert_status_ok();

let mut ws = connect_websocket(&server, session_id)
.await
.into_websocket()
.await;

ws.send_json(&json!({
"jsonrpc": "2.0",
"id": "timeout-1",
"method": "execute_code",
"params": {
"code": "async function run() { return await TestMath.add({a: 8, b: 2}); }",
"tool_timeout_secs": 600,
"tool_timeout_overrides": { "test_math__add": 1 }
}
}))
.await;

// Server asks the client to run the tool; we deliberately never respond.
let msg: WsJsonRpcMessage = ws.receive_json().await;
let (add_msg, _req_id) = msg.into_request().unwrap();
assert_eq!(json!(add_msg)["params"]["name"], json!("add"));

let response: serde_json::Value = ws.receive_json().await;
assert_eq!(response["result"]["success"], json!(false));
let stderr = response["result"]["stderr"].as_str().unwrap();
assert!(
stderr.contains("Tool `test_math__add` timed out after 1s"),
"expected a 1s timeout, got: {stderr}"
);
}

/// The request-wide default applies to a tool with no override, and the timeout
/// surfaces as a catchable error rather than killing the whole execution.
#[tokio::test]
#[serial]
async fn test_exec_callback_timeout_request_default() {
let (session_id, server, _) = create_test_server_with_session().await;

let test_tools: Vec<CallbackConfig> = callback_tools().into_iter().map(|(c, _)| c).collect();
let register_res = server
.post("/register/tools")
.add_header(CODE_MODE_SESSION_HEADER, session_id.to_string())
.json(&json!({ "tools": test_tools }))
.await;
register_res.assert_status_ok();

let mut ws = connect_websocket(&server, session_id)
.await
.into_websocket()
.await;

let code = "
async function run() {
try {
await TestMath.add({a: 8, b: 2});
return \"no timeout\";
} catch (e) {
return `caught: ${String(e)}`;
}
}";

ws.send_json(&json!({
"jsonrpc": "2.0",
"id": "timeout-2",
"method": "execute_code",
"params": {
"code": code,
"tool_timeout_secs": 1
}
}))
.await;

// Server asks the client to run the tool; we deliberately never respond.
let msg: WsJsonRpcMessage = ws.receive_json().await;
let (add_msg, _req_id) = msg.into_request().unwrap();
assert_eq!(json!(add_msg)["params"]["name"], json!("add"));

let response: serde_json::Value = ws.receive_json().await;
assert_eq!(response["result"]["success"], json!(true));
let output = response["result"]["output"].as_str().unwrap();
assert!(
output.contains("Tool `test_math__add` timed out after 1s"),
"expected the timeout to be catchable in TS, got: {output}"
);
}
10 changes: 10 additions & 0 deletions pctx-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For changes to the underlying Rust crates and CLI, see the
[root CHANGELOG](../CHANGELOG.md).

## [UNRELEASED] - YYYY-MM-DD

### Added

- `Pctx.execute_typescript(tool_timeout_secs=..., tool_timeout_overrides=...)`:
bound each individual tool call. `tool_timeout_overrides` keys tools by id
(`"namespace__name"`). Both optional, default 30s, clamped to 1–600s.
Still capped by the client-wide `Pctx(execute_timeout=...)` (default 30s),
so raise that alongside any per-tool value above 30.

## [v0.4.2] - 2026-07-17

### Added
Expand Down
1 change: 1 addition & 0 deletions pctx-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dev = [
"langchain-openai>=0.3.0",
"mcp>=1.25.0",
"ty>=0.0.34",
"google-auth>=2.0.0",
]

[tool.ruff.lint.pydocstyle]
Expand Down
12 changes: 12 additions & 0 deletions pctx-py/src/pctx_client/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ async def execute_typescript(
self,
code: str,
disclosure: ToolDisclosure | ToolDisclosureName = ToolDisclosure.CATALOG,
tool_timeout_secs: int | None = None,
tool_timeout_overrides: dict[str, int] | None = None,
) -> ExecuteTypescriptOutput:
"""
Execute TypeScript code that calls namespaced functions.
Expand All @@ -323,6 +325,14 @@ async def execute_typescript(
code: TypeScript code to execute. Must include an async `run()` function
that serves as the entry point. Functions must be called with their
namespace prefix (e.g., 'Weather.getCurrentWeather()').
tool_timeout_secs: Timeout in seconds applied to each individual tool
call the code makes (server default 30, max 600). This bounds one
call, not the execution as a whole — code making N sequential
calls can still run for N times this value, up to the client-wide
execute_timeout.
tool_timeout_overrides: Per-tool overrides of tool_timeout_secs, keyed
by tool id ("namespace__name", or "name" if there is no
namespace). Ids that match no registered tool are ignored.

Returns:
ExecuteTypescriptOutput: An object containing execution results with attributes:
Expand Down Expand Up @@ -364,6 +374,8 @@ async def execute_typescript(
code,
disclosure=ToolDisclosure(disclosure),
timeout=self._execute_timeout,
tool_timeout_secs=tool_timeout_secs,
tool_timeout_overrides=tool_timeout_overrides,
)

async def execute(
Expand Down
15 changes: 13 additions & 2 deletions pctx-py/src/pctx_client/_websocket_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,20 @@ async def execute_typescript(
code: str,
disclosure: ToolDisclosure = ToolDisclosure.CATALOG,
timeout: float = 30.0,
tool_timeout_secs: int | None = None,
tool_timeout_overrides: dict[str, int] | None = None,
) -> ExecuteTypescriptOutput:
"""
Execute code via WebSocket instead of REST.

Args:
code_mode_session: CodeMode session to run execution in
code: TypeScript/JavaScript code to execute
timeout: Timeout in seconds (default 30)
timeout: Timeout in seconds for the whole execution (default 30)
tool_timeout_secs: Timeout in seconds applied to each individual tool
call made by the code (server default 30, max 600)
tool_timeout_overrides: Per-tool overrides of tool_timeout_secs, keyed
by tool id ("namespace__name", or "name" if there is no namespace)

Returns:
ExecuteTypescriptOutput with success, stdout, stderr, and output
Expand All @@ -144,7 +150,12 @@ async def execute_typescript(
request = ExecuteCodeRequest(
id=request_id,
method="execute_typescript",
params=ExecuteCodeParams(code=code, disclosure=disclosure),
params=ExecuteCodeParams(
code=code,
disclosure=disclosure,
tool_timeout_secs=tool_timeout_secs,
tool_timeout_overrides=tool_timeout_overrides or {},
),
)

try:
Expand Down
2 changes: 2 additions & 0 deletions pctx-py/src/pctx_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ class JsonRpcError(JsonRpcBase):
class ExecuteCodeParams(BaseModel):
code: str
disclosure: ToolDisclosure = ToolDisclosure.CATALOG
tool_timeout_secs: int | None = None
tool_timeout_overrides: dict[str, int] = Field(default_factory=dict)


class ExecuteCodeRequest(JsonRpcBase):
Expand Down
Loading
Loading