@senpi/runtime 3.0.100 → 3.0.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -13,6 +13,7 @@ on error/timeout/malformed/failed-persist — transactional discard wins on fail
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import inspect
|
|
16
|
+
import math
|
|
16
17
|
import os
|
|
17
18
|
import time
|
|
18
19
|
import threading
|
|
@@ -38,6 +39,35 @@ from .state_manager import StateManager
|
|
|
38
39
|
from .ulid import new_ulid
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
def _read_call_timeout_seconds(senpi_mcp_mod: Any) -> float:
|
|
43
|
+
"""The per-call MCP bound from the launch env, or the documented default.
|
|
44
|
+
|
|
45
|
+
`SENPI_MCP_CALL_TIMEOUT_SECONDS` is operator-set on the runtime box and
|
|
46
|
+
reaches the scaffold child through the supervisor's inherited env. A value
|
|
47
|
+
that is not a positive finite number must not take the scanner down at
|
|
48
|
+
launch — it falls back to the default and says so (`senpi_mcp_config_invalid`),
|
|
49
|
+
the same "loud, not fatal" shape as a malformed launch config field.
|
|
50
|
+
"""
|
|
51
|
+
variable = senpi_mcp_mod.CALL_TIMEOUT_ENV_VAR
|
|
52
|
+
default = senpi_mcp_mod.DEFAULT_CALL_TIMEOUT_SECONDS
|
|
53
|
+
raw = os.environ.get(variable)
|
|
54
|
+
if raw is None:
|
|
55
|
+
return default
|
|
56
|
+
try:
|
|
57
|
+
seconds = float(raw)
|
|
58
|
+
except ValueError:
|
|
59
|
+
seconds = math.nan
|
|
60
|
+
if not senpi_mcp_mod.is_valid_call_timeout(seconds):
|
|
61
|
+
log_event(
|
|
62
|
+
"senpi_mcp_config_invalid",
|
|
63
|
+
variable=variable,
|
|
64
|
+
value=raw[:100],
|
|
65
|
+
fallback_seconds=default,
|
|
66
|
+
)
|
|
67
|
+
return default
|
|
68
|
+
return seconds
|
|
69
|
+
|
|
70
|
+
|
|
41
71
|
def _build_senpi_mcp() -> Any:
|
|
42
72
|
"""Construct the MCP client when creds are present; else return None.
|
|
43
73
|
|
|
@@ -45,7 +75,8 @@ def _build_senpi_mcp() -> Any:
|
|
|
45
75
|
the launcher regardless of how the operator supplied them. With neither set,
|
|
46
76
|
return None so the no-creds path does not crash — the client is loud at FIRST
|
|
47
77
|
USE (see scaffold.senpi_mcp). Reference the class through the module attribute
|
|
48
|
-
so a test stub patched onto scaffold.senpi_mcp is honored.
|
|
78
|
+
so a test stub patched onto scaffold.senpi_mcp is honored. The per-call bound
|
|
79
|
+
comes from SENPI_MCP_CALL_TIMEOUT_SECONDS (see _read_call_timeout_seconds).
|
|
49
80
|
"""
|
|
50
81
|
api_key = os.environ.get("SENPI_AUTH_TOKEN") or os.environ.get("SENPI_API_KEY") or None
|
|
51
82
|
mcp_url = os.environ.get("SENPI_MCP_URL") or None
|
|
@@ -63,6 +94,7 @@ def _build_senpi_mcp() -> Any:
|
|
|
63
94
|
api_key=api_key,
|
|
64
95
|
mcp_url=mcp_url,
|
|
65
96
|
write_allowlist=senpi_mcp_mod.PRODUCER_WRITE_ALLOWLIST,
|
|
97
|
+
call_timeout_seconds=_read_call_timeout_seconds(senpi_mcp_mod),
|
|
66
98
|
)
|
|
67
99
|
|
|
68
100
|
|
|
@@ -30,9 +30,22 @@ report `ok` with zero candidates. The session is therefore now REBUILT: a
|
|
|
30
30
|
transport-level failure (closed/broken stream, connection reset, broken pipe,
|
|
31
31
|
httpx transport error) discards the dead session and the NEXT call opens and
|
|
32
32
|
initialises a fresh one. The failing call still fails — there is no retry
|
|
33
|
-
inside a call
|
|
34
|
-
failure is
|
|
35
|
-
|
|
33
|
+
inside a call — the point is that the failure is no longer permanent. The
|
|
34
|
+
failure is logged once as `senpi_mcp_error` with `session_rebuilt: true`.
|
|
35
|
+
|
|
36
|
+
AMENDMENT, second half (same RCA): B5 also locked "no per-call timeout".
|
|
37
|
+
`call_tool` blocked on the loop's future with no bound, so one stalled
|
|
38
|
+
`POST /mcp` hung a call until Kong severed the stream at 60 s, and a handful of
|
|
39
|
+
such calls consumed the whole 300 s tick budget (`scaffold_tick_timeout`).
|
|
40
|
+
EVERY call is now BOUNDED — session open included — by
|
|
41
|
+
`call_timeout_seconds` (launch env `SENPI_MCP_CALL_TIMEOUT_SECONDS`, default
|
|
42
|
+
`DEFAULT_CALL_TIMEOUT_SECONDS`, well under the 60 s ceiling so the scaffold,
|
|
43
|
+
not the proxy, is what gives up). A call that outlives the bound raises
|
|
44
|
+
`SenpiMcpTimeoutError` (a `TimeoutError`) and is treated exactly like a
|
|
45
|
+
transport failure: the abandoned session is discarded — the server may still
|
|
46
|
+
write to that stream — and the next call opens a fresh one. It is logged as
|
|
47
|
+
`senpi_mcp_error` with `error_type: TimeoutError`, `timeout_seconds` and
|
|
48
|
+
`elapsed_ms` so a stall is distinguishable from a severed stream in ClickStack.
|
|
36
49
|
|
|
37
50
|
Response normalization MATCHES producer.mjs `createSenpiToolClient`:
|
|
38
51
|
1. isError truthy -> RAISE with the error text (never a silent None).
|
|
@@ -82,9 +95,11 @@ connection (streamable-http is request/response, no protocol goodbye).
|
|
|
82
95
|
from __future__ import annotations
|
|
83
96
|
|
|
84
97
|
import asyncio
|
|
98
|
+
import concurrent.futures
|
|
85
99
|
import json
|
|
86
100
|
import math
|
|
87
101
|
import threading
|
|
102
|
+
import time
|
|
88
103
|
from typing import Any, Iterable, Optional
|
|
89
104
|
|
|
90
105
|
from .loop_primitives import log_event
|
|
@@ -388,9 +403,11 @@ def _cast_market_numerics(name: str, payload: Any, args: Optional[dict] = None)
|
|
|
388
403
|
# three when the SDK's streams are closed or broken under a session; httpx's
|
|
389
404
|
# `TransportError` is the base of every connect/read/write/protocol/timeout
|
|
390
405
|
# failure on the POST itself. OSError covers ConnectionError and its subclasses
|
|
391
|
-
# (reset, refused, aborted), BrokenPipeError and — deliberately — TimeoutError
|
|
392
|
-
#
|
|
393
|
-
#
|
|
406
|
+
# (reset, refused, aborted), BrokenPipeError and — deliberately — TimeoutError,
|
|
407
|
+
# which is what the per-call bound raises (`SenpiMcpTimeoutError`): a call that
|
|
408
|
+
# timed out at the socket or under the bound has left the session in an unknown
|
|
409
|
+
# state (the server may still write to the abandoned stream) and the next call
|
|
410
|
+
# must not inherit it.
|
|
394
411
|
# Our own pre-connect raises (PermissionError for a blocked mutation tool,
|
|
395
412
|
# RuntimeError for missing creds) happen before any session is held, so they
|
|
396
413
|
# never reach this classification.
|
|
@@ -411,6 +428,42 @@ def _is_transport_error(exc: BaseException) -> bool:
|
|
|
411
428
|
return any(klass.__name__ in _TRANSPORT_ERROR_NAMES for klass in type(exc).__mro__)
|
|
412
429
|
|
|
413
430
|
|
|
431
|
+
# --------------------------------------------------------------------------
|
|
432
|
+
# Per-call bound — the scaffold gives up before the proxy does.
|
|
433
|
+
# --------------------------------------------------------------------------
|
|
434
|
+
#
|
|
435
|
+
# Kong severs a stalled `POST /mcp` at 60.000 s; a tick has 300 s. 30 s is
|
|
436
|
+
# comfortably under the ceiling — a stall is visible as OUR timeout, not as a
|
|
437
|
+
# severed stream — and still leaves ten sequential calls inside one tick. The
|
|
438
|
+
# bound covers the whole call: opening the session (initialize) when there is
|
|
439
|
+
# none, plus the tool call itself.
|
|
440
|
+
DEFAULT_CALL_TIMEOUT_SECONDS: float = 30.0
|
|
441
|
+
|
|
442
|
+
# Launch-env knob for the bound, read by scaffold._build_senpi_mcp.
|
|
443
|
+
CALL_TIMEOUT_ENV_VAR = "SENPI_MCP_CALL_TIMEOUT_SECONDS"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def is_valid_call_timeout(seconds: Any) -> bool:
|
|
447
|
+
"""A usable bound: a positive, finite number (bools are not numbers here).
|
|
448
|
+
|
|
449
|
+
Shared by the constructor (which rejects anything else) and the launch-env
|
|
450
|
+
reader in scaffold.py (which falls back to the default instead).
|
|
451
|
+
"""
|
|
452
|
+
if isinstance(seconds, bool) or not isinstance(seconds, (int, float)):
|
|
453
|
+
return False
|
|
454
|
+
return math.isfinite(seconds) and seconds > 0
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class SenpiMcpTimeoutError(TimeoutError):
|
|
458
|
+
"""A `call_tool` outlived the client's per-call bound.
|
|
459
|
+
|
|
460
|
+
A `TimeoutError` so authored `scan()` code can `except TimeoutError`; a
|
|
461
|
+
distinct class so the scaffold's own bound is tellable from a socket
|
|
462
|
+
timeout. Classified as a transport failure: the session it happened on is
|
|
463
|
+
discarded and the next call opens a fresh one.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
|
|
414
467
|
class _McpSession:
|
|
415
468
|
"""One live MCP session: the SDK session plus the handles that end it.
|
|
416
469
|
|
|
@@ -449,9 +502,18 @@ class SenpiMcpClient:
|
|
|
449
502
|
api_key: Optional[str] = None,
|
|
450
503
|
mcp_url: Optional[str] = None,
|
|
451
504
|
write_allowlist: Iterable[str] = (),
|
|
505
|
+
call_timeout_seconds: Optional[float] = None,
|
|
452
506
|
) -> None:
|
|
453
507
|
self._api_key = api_key
|
|
454
508
|
self._mcp_url = mcp_url
|
|
509
|
+
if call_timeout_seconds is None:
|
|
510
|
+
call_timeout_seconds = DEFAULT_CALL_TIMEOUT_SECONDS
|
|
511
|
+
if not is_valid_call_timeout(call_timeout_seconds):
|
|
512
|
+
raise ValueError(
|
|
513
|
+
"senpi_mcp: call_timeout_seconds must be a positive number, "
|
|
514
|
+
f"got {call_timeout_seconds!r}"
|
|
515
|
+
)
|
|
516
|
+
self._call_timeout = float(call_timeout_seconds)
|
|
455
517
|
# Mutation tools the operator has explicitly permitted. EMPTY by default
|
|
456
518
|
# → every MUTATION_TOOLS entry is blocked (read-only producer boundary).
|
|
457
519
|
self._write_allowlist: frozenset[str] = frozenset(write_allowlist)
|
|
@@ -466,6 +528,11 @@ class SenpiMcpClient:
|
|
|
466
528
|
|
|
467
529
|
# -- public sync surface ----------------------------------------------
|
|
468
530
|
|
|
531
|
+
@property
|
|
532
|
+
def call_timeout_seconds(self) -> float:
|
|
533
|
+
"""The bound every `call_tool` fails within (session open included)."""
|
|
534
|
+
return self._call_timeout
|
|
535
|
+
|
|
469
536
|
def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
|
|
470
537
|
"""Invoke an MCP tool synchronously; return the unwrapped result.
|
|
471
538
|
|
|
@@ -479,8 +546,16 @@ class SenpiMcpClient:
|
|
|
479
546
|
A transport-level failure (see `_is_transport_error`) additionally
|
|
480
547
|
discards the session it happened on, so the NEXT call opens a fresh one
|
|
481
548
|
instead of failing forever; its log event carries `session_rebuilt: true`.
|
|
549
|
+
|
|
550
|
+
The whole call — opening a session when there is none, then the tool
|
|
551
|
+
call — must finish within `call_timeout_seconds`, else it raises
|
|
552
|
+
`SenpiMcpTimeoutError` (logged with `error_type: TimeoutError` and the
|
|
553
|
+
elapsed ms) and the abandoned session is discarded like any other
|
|
554
|
+
transport failure.
|
|
482
555
|
"""
|
|
483
556
|
used: Optional[_McpSession] = None
|
|
557
|
+
started = time.monotonic()
|
|
558
|
+
deadline = started + self._call_timeout
|
|
484
559
|
try:
|
|
485
560
|
# Read-only producer boundary, enforced PRE-CONNECT (no transport
|
|
486
561
|
# opened for a blocked write). A mutation tool passes only if its exact
|
|
@@ -495,31 +570,38 @@ class SenpiMcpClient:
|
|
|
495
570
|
"not reachable by authored producer code)."
|
|
496
571
|
)
|
|
497
572
|
|
|
498
|
-
used = self._ensure_connected()
|
|
499
|
-
result = self._run(used.session.call_tool(name, arguments=args))
|
|
573
|
+
used = self._ensure_connected(name, deadline)
|
|
574
|
+
result = self._run(used.session.call_tool(name, arguments=args), name, deadline)
|
|
500
575
|
return _cast_market_numerics(name, self._unwrap(name, result), args)
|
|
501
576
|
except Exception as exc:
|
|
502
577
|
# Catch every failure — mutation block, missing creds, the lazy
|
|
503
|
-
# `import mcp`, transport errors, tool `isError` — log it,
|
|
504
|
-
# to the author's scan(). Scoped to Exception so a tick
|
|
505
|
-
# (_TickTimeout, a BaseException) still unwinds the tick untouched.
|
|
578
|
+
# `import mcp`, transport errors, timeouts, tool `isError` — log it,
|
|
579
|
+
# then re-raise to the author's scan(). Scoped to Exception so a tick
|
|
580
|
+
# timeout (_TickTimeout, a BaseException) still unwinds the tick untouched.
|
|
506
581
|
rebuilt = used is not None and _is_transport_error(exc) and self._discard(used)
|
|
582
|
+
timed_out = isinstance(exc, SenpiMcpTimeoutError)
|
|
507
583
|
log_event(
|
|
508
584
|
"senpi_mcp_error",
|
|
509
585
|
tool=name,
|
|
510
|
-
|
|
586
|
+
# The fleet fingerprint for the bound is the builtin category,
|
|
587
|
+
# not the scaffold subclass: ClickStack queries `TimeoutError`.
|
|
588
|
+
error_type="TimeoutError" if timed_out else type(exc).__name__,
|
|
511
589
|
error=str(exc)[:500],
|
|
590
|
+
elapsed_ms=round((time.monotonic() - started) * 1000, 1),
|
|
591
|
+
**({"timeout_seconds": self._call_timeout} if timed_out else {}),
|
|
512
592
|
**({"session_rebuilt": True} if rebuilt else {}),
|
|
513
593
|
)
|
|
514
594
|
raise
|
|
515
595
|
|
|
516
596
|
# -- persistent session lifecycle -------------------------------------
|
|
517
597
|
|
|
518
|
-
def _ensure_connected(self) -> _McpSession:
|
|
598
|
+
def _ensure_connected(self, name: str, deadline: float) -> _McpSession:
|
|
519
599
|
"""Return the live session, opening one when there is none.
|
|
520
600
|
|
|
521
601
|
A session whose lifetime task has ended on its own (the transport died
|
|
522
602
|
between calls) counts as absent and is replaced here, before the call.
|
|
603
|
+
Opening counts against the call's `deadline`: a stalled initialize
|
|
604
|
+
raises `SenpiMcpTimeoutError` like a stalled tool call would.
|
|
523
605
|
"""
|
|
524
606
|
current = self._current
|
|
525
607
|
if current is not None and current.alive:
|
|
@@ -555,7 +637,7 @@ class SenpiMcpClient:
|
|
|
555
637
|
)
|
|
556
638
|
self._thread.start()
|
|
557
639
|
|
|
558
|
-
self._current = self._run(self._open_session())
|
|
640
|
+
self._current = self._run(self._open_session(), name, deadline)
|
|
559
641
|
return self._current
|
|
560
642
|
|
|
561
643
|
def _discard(self, dead: _McpSession) -> bool:
|
|
@@ -579,12 +661,19 @@ class SenpiMcpClient:
|
|
|
579
661
|
|
|
580
662
|
A failure while opening propagates to the caller; the lifetime task has
|
|
581
663
|
already unwound whatever it had entered, so nothing is left half-built.
|
|
664
|
+
A cancelled open (the caller's bound ran out while initialize stalled)
|
|
665
|
+
cancels the lifetime task too, which unwinds the transport from inside
|
|
666
|
+
the task that entered it — a stalled open never leaks a session.
|
|
582
667
|
"""
|
|
583
668
|
loop = asyncio.get_running_loop()
|
|
584
669
|
ready: "asyncio.Future[Any]" = loop.create_future()
|
|
585
670
|
close_requested = asyncio.Event()
|
|
586
671
|
lifetime = loop.create_task(self._session_lifetime(ready, close_requested))
|
|
587
|
-
|
|
672
|
+
try:
|
|
673
|
+
session = await ready
|
|
674
|
+
except asyncio.CancelledError:
|
|
675
|
+
lifetime.cancel()
|
|
676
|
+
raise
|
|
588
677
|
return _McpSession(session, lifetime, close_requested)
|
|
589
678
|
|
|
590
679
|
async def _session_lifetime(self, ready: "asyncio.Future[Any]", close_requested: asyncio.Event) -> None:
|
|
@@ -619,10 +708,25 @@ class SenpiMcpClient:
|
|
|
619
708
|
if not isinstance(exc, Exception):
|
|
620
709
|
raise
|
|
621
710
|
|
|
622
|
-
def _run(self, coro: Any) -> Any:
|
|
623
|
-
"""Drive a coroutine
|
|
711
|
+
def _run(self, coro: Any, name: str, deadline: float) -> Any:
|
|
712
|
+
"""Drive a coroutine on the persistent loop, blocking until `deadline`.
|
|
713
|
+
|
|
714
|
+
Past the deadline the loop-side task is cancelled and
|
|
715
|
+
`SenpiMcpTimeoutError` is raised. If the task finished in the instant
|
|
716
|
+
between the deadline and the cancel, its outcome is taken as-is — the
|
|
717
|
+
call made it, by a hair — so a completed open never leaks its session.
|
|
718
|
+
"""
|
|
624
719
|
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
625
|
-
|
|
720
|
+
try:
|
|
721
|
+
return future.result(timeout=max(0.0, deadline - time.monotonic()))
|
|
722
|
+
except concurrent.futures.TimeoutError:
|
|
723
|
+
if not future.cancel():
|
|
724
|
+
return future.result()
|
|
725
|
+
raise SenpiMcpTimeoutError(
|
|
726
|
+
f'senpi_mcp: tool "{name}" did not complete within '
|
|
727
|
+
f"{self._call_timeout:g}s ({CALL_TIMEOUT_ENV_VAR}); the session (or "
|
|
728
|
+
"the pending open) is discarded and the next call opens a fresh one"
|
|
729
|
+
) from None
|
|
626
730
|
|
|
627
731
|
# -- response normalization (matches producer.mjs createSenpiToolClient) --
|
|
628
732
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "runtime",
|
|
3
3
|
"name": "Senpi Runtime",
|
|
4
4
|
"description": "Senpi runtime plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.",
|
|
5
|
-
"version": "3.0.
|
|
5
|
+
"version": "3.0.101",
|
|
6
6
|
"commandAliases": [
|
|
7
7
|
{
|
|
8
8
|
"name": "senpi"
|