From 491c30e09ed0f5f04e6883204581bba889e371ec Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Mon, 20 Jul 2026 17:11:00 -0400 Subject: [PATCH 1/2] timeouts --- crates/pctx_session_server/src/model.rs | 49 +++++ .../src/state/ws_manager.rs | 17 +- .../src/websocket/handler.rs | 14 +- .../pctx_session_server/tests/executions.rs | 101 +++++++++ pctx-py/pyproject.toml | 1 + pctx-py/src/pctx_client/_client.py | 12 + pctx-py/src/pctx_client/_websocket_client.py | 15 +- pctx-py/src/pctx_client/models.py | 2 + pctx-py/uv.lock | 208 +++++++++--------- 9 files changed, 304 insertions(+), 115 deletions(-) diff --git a/crates/pctx_session_server/src/model.rs b/crates/pctx_session_server/src/model.rs index ff322825..ec0d94ed 100644 --- a/crates/pctx_session_server/src/model.rs +++ b/crates/pctx_session_server/src/model.rs @@ -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}; @@ -131,11 +133,58 @@ pub struct ExecuteToolParams { pub args: Option, } +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, + /// 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, +} + +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)] diff --git a/crates/pctx_session_server/src/state/ws_manager.rs b/crates/pctx_session_server/src/state/ws_manager.rs index f426959d..f8b8cda9 100644 --- a/crates/pctx_session_server/src/state/ws_manager.rs +++ b/crates/pctx_session_server/src/state/ws_manager.rs @@ -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}; @@ -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)] @@ -144,8 +146,10 @@ impl WsSession { pub async fn execute_callback( &self, params: ExecuteToolParams, + timeout: Duration, ) -> Result { 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(); @@ -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; @@ -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, + }), } } diff --git a/crates/pctx_session_server/src/websocket/handler.rs b/crates/pctx_session_server/src/websocket/handler.rs index 466c24ca..69381bd3 100644 --- a/crates/pctx_session_server/src/websocket/handler.rs +++ b/crates/pctx_session_server/src/websocket/handler.rs @@ -226,6 +226,7 @@ async fn handle_execute_code_request( 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| { let cfg = cfg.clone(); @@ -235,11 +236,14 @@ async fn handle_execute_code_request( 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())?; diff --git a/crates/pctx_session_server/tests/executions.rs b/crates/pctx_session_server/tests/executions.rs index 281ecc3f..07e92ccf 100644 --- a/crates/pctx_session_server/tests/executions.rs +++ b/crates/pctx_session_server/tests/executions.rs @@ -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 = 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 = 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}" + ); +} diff --git a/pctx-py/pyproject.toml b/pctx-py/pyproject.toml index b55d4452..17281f37 100644 --- a/pctx-py/pyproject.toml +++ b/pctx-py/pyproject.toml @@ -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] diff --git a/pctx-py/src/pctx_client/_client.py b/pctx-py/src/pctx_client/_client.py index 2f320c58..98b5202b 100644 --- a/pctx-py/src/pctx_client/_client.py +++ b/pctx-py/src/pctx_client/_client.py @@ -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. @@ -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: @@ -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( diff --git a/pctx-py/src/pctx_client/_websocket_client.py b/pctx-py/src/pctx_client/_websocket_client.py index c50e5de0..494d567a 100644 --- a/pctx-py/src/pctx_client/_websocket_client.py +++ b/pctx-py/src/pctx_client/_websocket_client.py @@ -114,6 +114,8 @@ 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. @@ -121,7 +123,11 @@ async def execute_typescript( 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 @@ -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: diff --git a/pctx-py/src/pctx_client/models.py b/pctx-py/src/pctx_client/models.py index 852c6e86..05213ef4 100644 --- a/pctx-py/src/pctx_client/models.py +++ b/pctx-py/src/pctx_client/models.py @@ -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): diff --git a/pctx-py/uv.lock b/pctx-py/uv.lock index 1642a4bd..e6038067 100644 --- a/pctx-py/uv.lock +++ b/pctx-py/uv.lock @@ -1779,17 +1779,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -1806,17 +1806,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } wheels = [ @@ -1828,7 +1828,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2362,7 +2362,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.11'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -2379,7 +2379,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.11'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -2825,12 +2825,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -2847,12 +2847,12 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" } @@ -3441,7 +3441,7 @@ wheels = [ [[package]] name = "pctx-client" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "docstring-parser" }, @@ -3473,6 +3473,7 @@ pydantic-ai = [ [package.dev-dependencies] dev = [ + { name = "google-auth" }, { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "langchain-openai" }, @@ -3513,6 +3514,7 @@ provides-extras = ["langchain", "crewai", "openai", "pydantic-ai", "bm25s", "cla [package.metadata.requires-dev] dev = [ + { name = "google-auth", specifier = ">=2.0.0" }, { name = "ipython", specifier = ">=8.26" }, { name = "langchain-openai", specifier = ">=0.3.0" }, { name = "litellm", specifier = ">=1.80.8" }, @@ -5063,7 +5065,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -5124,7 +5126,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ @@ -5247,23 +5249,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -5278,23 +5280,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version == '3.11.*'" }, - { name = "babel", marker = "python_full_version == '3.11.*'" }, - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", marker = "python_full_version == '3.11.*'" }, - { name = "jinja2", marker = "python_full_version == '3.11.*'" }, - { name = "packaging", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", marker = "python_full_version == '3.11.*'" }, - { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -5310,23 +5312,23 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ @@ -5341,12 +5343,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "starlette", marker = "python_full_version < '3.11'" }, - { name = "uvicorn", marker = "python_full_version < '3.11'" }, - { name = "watchfiles", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } wheels = [ @@ -5363,13 +5365,13 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "colorama" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "starlette", marker = "python_full_version >= '3.11'" }, - { name = "uvicorn", marker = "python_full_version >= '3.11'" }, - { name = "watchfiles", marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ From ea849bbcd105c312e03655e7d218ca6c9d52d276 Mon Sep 17 00:00:00 2001 From: Elias Posen Date: Mon, 20 Jul 2026 17:21:22 -0400 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 4 ++++ pctx-py/CHANGELOG.md | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53a1517..e92b34c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pctx-py/CHANGELOG.md b/pctx-py/CHANGELOG.md index 2f53dfcb..c3cf636e 100644 --- a/pctx-py/CHANGELOG.md +++ b/pctx-py/CHANGELOG.md @@ -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