Skip to content

Commit bbe28b2

Browse files
committed
fix(tools): validate cancellation timeout bounds
1 parent 740ecc9 commit bbe28b2

4 files changed

Lines changed: 29 additions & 8 deletions

File tree

packages/pythinker-core/src/pythinker_core/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,13 +285,9 @@ def completed_tool_results(self) -> dict[str, ToolResult]:
285285

286286
completed: dict[str, ToolResult] = {}
287287
for tool_call_id, future in self._tool_result_futures.items():
288-
if not future.done() or future.cancelled():
288+
if not future.done() or future.cancelled() or future.exception() is not None:
289289
continue
290-
try:
291-
result = future.result()
292-
except BaseException:
293-
continue
294-
completed[tool_call_id] = result
290+
completed[tool_call_id] = future.result()
295291
return completed
296292

297293
@property

packages/pythinker-core/src/pythinker_core/tooling/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,13 +338,17 @@ class ToolCancellationTimeoutError(RuntimeError):
338338

339339
@dataclass(frozen=True, slots=True)
340340
class ToolBatchContext:
341+
"""Immutable cross-step execution metadata supplied when a batch is created."""
342+
341343
turn_id: str = ""
342344
step_no: int = 0
343345
prior_call_fingerprints: tuple[ToolCallFingerprint, ...] = ()
344346

345347

346348
@dataclass(frozen=True, slots=True)
347349
class ToolBatchSummary:
350+
"""Final normalized call and deduplication state exposed by a batch handle."""
351+
348352
current_call_fingerprints: tuple[ToolCallFingerprint, ...] = ()
349353
dedup_triggered: bool = False
350354
consecutive_identical_call_count: int = 0
@@ -353,6 +357,8 @@ class ToolBatchSummary:
353357

354358
@runtime_checkable
355359
class ToolBatchHandle(Protocol):
360+
"""Supervises one exception-atomic terminal batch and its ordered results."""
361+
356362
@property
357363
def tool_calls(self) -> Sequence[ToolCall]: ...
358364

@@ -369,6 +375,8 @@ async def cancel_and_settle(self, *, timeout: float | None = None) -> None: ...
369375

370376
@runtime_checkable
371377
class BatchToolset(Protocol):
378+
"""Optional additive Toolset protocol for terminal batch dispatch."""
379+
372380
def handle_batch(
373381
self,
374382
tool_calls: Sequence[ToolCall],

src/pythinker_code/soul/tool_execution.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import difflib
77
import hashlib
88
import json
9+
import math
910
import time
1011
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
1112
from contextvars import ContextVar
@@ -891,8 +892,8 @@ async def _bounded_settlement(self, timeout: float) -> None:
891892

892893
async def cancel_and_settle(self, *, timeout: float | None = None) -> None:
893894
effective_timeout = TOOL_CANCELLATION_TIMEOUT_SECONDS if timeout is None else timeout
894-
if effective_timeout < 0:
895-
raise ValueError("tool cancellation timeout cannot be negative")
895+
if not math.isfinite(effective_timeout) or effective_timeout < 0:
896+
raise ValueError("tool cancellation timeout must be finite and non-negative")
896897

897898
settlement = self._settlement_task
898899
if settlement is None:

tests/core/test_tool_execution_cancellation.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ def test_cancellation_timeout_default_is_five_seconds() -> None:
8787
assert tool_execution.TOOL_CANCELLATION_TIMEOUT_SECONDS == 5.0
8888

8989

90+
@pytest.mark.parametrize("timeout", [-1.0, float("nan"), float("inf")])
91+
async def test_invalid_cancellation_timeout_does_not_cancel_work(timeout: float) -> None:
92+
stubborn = CancellationIgnoringTool()
93+
toolset = PythinkerToolset()
94+
toolset.add(stubborn)
95+
batch = toolset.handle_batch([_call("stubborn", "Stubborn")], ToolBatchContext())
96+
await stubborn.started.wait()
97+
98+
with pytest.raises(ValueError, match="finite and non-negative"):
99+
await batch.cancel_and_settle(timeout=timeout)
100+
101+
assert not stubborn.cancel_seen.is_set()
102+
stubborn.release.set()
103+
assert [result.tool_call_id for result in await batch.results()] == ["stubborn"]
104+
105+
90106
async def test_timeout_poisons_new_batches_until_late_task_is_drained() -> None:
91107
stubborn = CancellationIgnoringTool()
92108
immediate = ImmediateTool()

0 commit comments

Comments
 (0)