soothe-client-python 0.9.4__py3-none-any.whl
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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Persistence seam for appkit (RFC-629 Layer 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SessionEntry:
|
|
9
|
+
"""Persisted mapping between an application session id and a daemon loop id."""
|
|
10
|
+
|
|
11
|
+
__slots__ = (
|
|
12
|
+
"workspace_id",
|
|
13
|
+
"session_id",
|
|
14
|
+
"loop_id",
|
|
15
|
+
"session_type",
|
|
16
|
+
"purpose",
|
|
17
|
+
"is_active",
|
|
18
|
+
"reset_count",
|
|
19
|
+
"last_used_at",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
workspace_id: str,
|
|
26
|
+
session_id: str,
|
|
27
|
+
loop_id: str,
|
|
28
|
+
session_type: str,
|
|
29
|
+
purpose: str | None = None,
|
|
30
|
+
is_active: bool = True,
|
|
31
|
+
reset_count: int = 0,
|
|
32
|
+
last_used_at: float = 0.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.workspace_id = workspace_id
|
|
35
|
+
self.session_id = session_id
|
|
36
|
+
self.loop_id = loop_id
|
|
37
|
+
self.session_type = session_type
|
|
38
|
+
self.purpose = purpose
|
|
39
|
+
self.is_active = is_active
|
|
40
|
+
self.reset_count = reset_count
|
|
41
|
+
self.last_used_at = last_used_at
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionMessage:
|
|
45
|
+
"""A persisted message row (assistant reply or error)."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("id", "role", "content", "context", "metadata")
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
role: str,
|
|
53
|
+
content: str,
|
|
54
|
+
id: str | None = None, # noqa: A002
|
|
55
|
+
context: Any = None,
|
|
56
|
+
metadata: dict[str, Any] | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.id = id
|
|
59
|
+
self.role = role
|
|
60
|
+
self.content = content
|
|
61
|
+
self.context = context
|
|
62
|
+
self.metadata = metadata
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@runtime_checkable
|
|
66
|
+
class SessionStore(Protocol):
|
|
67
|
+
"""Persistence seam between appkit and the application's storage backend."""
|
|
68
|
+
|
|
69
|
+
async def get_session(self, session_id: str) -> SessionEntry | None:
|
|
70
|
+
"""Return the persisted entry for ``session_id``, or None."""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
async def create_session(
|
|
74
|
+
self,
|
|
75
|
+
workspace_id: str,
|
|
76
|
+
session_id: str,
|
|
77
|
+
loop_id: str,
|
|
78
|
+
session_type: str,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Persist a new session↔loop mapping."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
async def update_last_used(self, session_id: str) -> None:
|
|
84
|
+
"""Stamp the session's last-used timestamp."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
async def increment_reset_count(self, session_id: str) -> None:
|
|
88
|
+
"""Bump the reset counter (fresh bootstrap vs reattach)."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
async def get_loop_id_for_session(self, session_id: str) -> tuple[str, bool]:
|
|
92
|
+
"""Return ``(loop_id, ok)``; ``ok is False`` triggers fresh ``loop_new``."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
async def append_message(self, session_id: str, message: SessionMessage) -> None:
|
|
96
|
+
"""Write a message row for the session."""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["SessionEntry", "SessionMessage", "SessionStore"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Thinking-step extraction for appkit (RFC-629 Layer 1).
|
|
2
|
+
|
|
3
|
+
Maps an allowlisted progress event to one structured UI line. Free-form
|
|
4
|
+
streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MAX_THINKING_STEP_RUNES = 280
|
|
13
|
+
|
|
14
|
+
DEFAULT_THINKING_STEP_EVENTS: frozenset[str] = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"soothe.cognition.plan.step.started",
|
|
17
|
+
"soothe.cognition.plan.step.completed",
|
|
18
|
+
"soothe.cognition.plan.step.failed",
|
|
19
|
+
"soothe.lifecycle.iteration.started",
|
|
20
|
+
"soothe.agent.loop.step.started",
|
|
21
|
+
"soothe.agent.loop.started",
|
|
22
|
+
"soothe.cognition.plan.batch.started",
|
|
23
|
+
"soothe.cognition.plan.created",
|
|
24
|
+
"soothe.cognition.goal.created",
|
|
25
|
+
"soothe.tool.execution.started",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_thinking_step(
|
|
31
|
+
event_type: str,
|
|
32
|
+
data: Mapping[str, Any] | None,
|
|
33
|
+
allow: frozenset[str] | set[str] | None = None,
|
|
34
|
+
) -> tuple[str, bool]:
|
|
35
|
+
"""Map an allowlisted progress event to one structured UI line.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
event_type: Wire event type string.
|
|
39
|
+
data: Event payload map.
|
|
40
|
+
allow: Optional override of the default thinking-step allowlist.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
``(line, True)`` for a recognized event; ``("", False)`` otherwise.
|
|
44
|
+
"""
|
|
45
|
+
if not event_type or data is None:
|
|
46
|
+
return "", False
|
|
47
|
+
et = event_type.strip()
|
|
48
|
+
if not et:
|
|
49
|
+
return "", False
|
|
50
|
+
|
|
51
|
+
allowlist = allow if allow is not None else DEFAULT_THINKING_STEP_EVENTS
|
|
52
|
+
if et not in allowlist:
|
|
53
|
+
return "", False
|
|
54
|
+
|
|
55
|
+
line = ""
|
|
56
|
+
if et == "soothe.cognition.plan.step.started":
|
|
57
|
+
line = _format_plan_step_line(data, "")
|
|
58
|
+
elif et == "soothe.cognition.plan.step.completed":
|
|
59
|
+
line = _format_plan_step_line(data, "done")
|
|
60
|
+
elif et == "soothe.cognition.plan.step.failed":
|
|
61
|
+
step_id = _str_field(data, "step_id")
|
|
62
|
+
err_msg = _str_field(data, "error")
|
|
63
|
+
if step_id and err_msg:
|
|
64
|
+
line = f"Step {step_id} failed: {err_msg}"
|
|
65
|
+
elif step_id:
|
|
66
|
+
line = f"Step {step_id} failed"
|
|
67
|
+
elif err_msg:
|
|
68
|
+
line = f"Step failed: {err_msg}"
|
|
69
|
+
elif et == "soothe.agent.loop.step.started":
|
|
70
|
+
line = _format_agent_step_line(data, "")
|
|
71
|
+
elif et == "soothe.cognition.plan.batch.started":
|
|
72
|
+
n = data.get("parallel_count")
|
|
73
|
+
if isinstance(n, (int, float)) and n > 0:
|
|
74
|
+
line = f"Running {int(n)} steps in parallel"
|
|
75
|
+
elif et in ("soothe.cognition.plan.created", "soothe.agent.loop.started"):
|
|
76
|
+
g = _str_field(data, "goal")
|
|
77
|
+
if g:
|
|
78
|
+
line = f"Goal: {g}"
|
|
79
|
+
elif et == "soothe.cognition.goal.created":
|
|
80
|
+
g = _str_field(data, "friendly_message", "description")
|
|
81
|
+
if g:
|
|
82
|
+
line = f"Goal: {g}"
|
|
83
|
+
elif et == "soothe.lifecycle.iteration.started":
|
|
84
|
+
g = _str_field(data, "goal_description")
|
|
85
|
+
if g:
|
|
86
|
+
line = f"Iteration: {g}"
|
|
87
|
+
elif et == "soothe.tool.execution.started":
|
|
88
|
+
name = _str_field(data, "tool_name", "name")
|
|
89
|
+
if name:
|
|
90
|
+
line = f"Tool: {name}"
|
|
91
|
+
else:
|
|
92
|
+
return "", False
|
|
93
|
+
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if not line:
|
|
96
|
+
return "", False
|
|
97
|
+
runes = list(line)
|
|
98
|
+
if len(runes) > MAX_THINKING_STEP_RUNES:
|
|
99
|
+
line = "".join(runes[:MAX_THINKING_STEP_RUNES]) + "…"
|
|
100
|
+
return line, True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _format_plan_step_line(data: Mapping[str, Any], suffix: str) -> str:
|
|
104
|
+
step_id = _str_field(data, "step_id")
|
|
105
|
+
desc = _str_field(data, "description")
|
|
106
|
+
if step_id and suffix:
|
|
107
|
+
return f"Step {step_id}: {suffix}"
|
|
108
|
+
if step_id and desc:
|
|
109
|
+
return f"Step {step_id}: {desc}"
|
|
110
|
+
if step_id:
|
|
111
|
+
return f"Step {step_id}"
|
|
112
|
+
if desc and suffix:
|
|
113
|
+
return f"Step: {suffix}"
|
|
114
|
+
if desc:
|
|
115
|
+
return f"Step: {desc}"
|
|
116
|
+
if suffix:
|
|
117
|
+
return f"Step: {suffix}"
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _format_agent_step_line(data: Mapping[str, Any], suffix: str) -> str:
|
|
122
|
+
step_id = _str_field(data, "step_id")
|
|
123
|
+
desc = _str_field(data, "description")
|
|
124
|
+
if step_id and desc:
|
|
125
|
+
return f"Step {step_id}: {desc}"
|
|
126
|
+
if desc:
|
|
127
|
+
return f"Step: {suffix}" if suffix else f"Step: {desc}"
|
|
128
|
+
if step_id:
|
|
129
|
+
return f"Step {step_id}"
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _str_field(data: Mapping[str, Any], *keys: str) -> str:
|
|
134
|
+
for key in keys:
|
|
135
|
+
val = data.get(key)
|
|
136
|
+
if isinstance(val, str):
|
|
137
|
+
s = val.strip()
|
|
138
|
+
if s:
|
|
139
|
+
return s
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = [
|
|
144
|
+
"DEFAULT_THINKING_STEP_EVENTS",
|
|
145
|
+
"MAX_THINKING_STEP_RUNES",
|
|
146
|
+
"extract_thinking_step",
|
|
147
|
+
]
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Decouple daemon stream ingestion from chunk processing and UI application.
|
|
2
|
+
|
|
3
|
+
Three stages run concurrently during a turn:
|
|
4
|
+
|
|
5
|
+
1. **Reader** (async): pulls chunks from the daemon and enqueues them.
|
|
6
|
+
2. **Processor** (dedicated thread): runs CPU-heavy parsing without blocking the UI.
|
|
7
|
+
3. **Applier** (async): consumes prepared plans and performs product-specific updates.
|
|
8
|
+
|
|
9
|
+
High-priority chunks (tool wire, loop step events) are applied before low-priority text.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import AsyncIterator, Callable
|
|
20
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
21
|
+
|
|
22
|
+
from soothe_sdk.ux.loop_stream import assistant_output_phase
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
_SENTINEL = object()
|
|
28
|
+
|
|
29
|
+
# Lower number = higher priority (matches asyncio.PriorityQueue ordering).
|
|
30
|
+
PRIORITY_CRITICAL = -1 # step lifecycle / plan progress — never evict from queue
|
|
31
|
+
PRIORITY_HIGH = 0
|
|
32
|
+
PRIORITY_NORMAL = 1
|
|
33
|
+
PRIORITY_LOW = 2
|
|
34
|
+
|
|
35
|
+
_DEFAULT_BATCH_SIZE = 10
|
|
36
|
+
_DEFAULT_BATCH_DELAY_MS = 50
|
|
37
|
+
_DEFAULT_INBOUND_MAXSIZE = 2048
|
|
38
|
+
_DEFAULT_OUTBOUND_MAXSIZE = 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SupportsTurnLatency(Protocol):
|
|
42
|
+
"""Minimal latency recorder used by ``run_turn_pipeline``."""
|
|
43
|
+
|
|
44
|
+
turn_start_monotonic: float
|
|
45
|
+
|
|
46
|
+
def record_first_chunk(self) -> None:
|
|
47
|
+
"""Record time-to-first-chunk once per turn."""
|
|
48
|
+
|
|
49
|
+
def record_goal_completion(self) -> None:
|
|
50
|
+
"""Record synthesis-visible latency once per turn."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TurnApplyBatcher(Generic[T]):
|
|
54
|
+
"""Accumulate prepared chunks for batched apply.
|
|
55
|
+
|
|
56
|
+
HIGH priority items trigger immediate flush for responsiveness.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
max_batch_size: int = _DEFAULT_BATCH_SIZE,
|
|
63
|
+
max_batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._max_batch_size = max_batch_size
|
|
66
|
+
self._max_batch_delay_s = max_batch_delay_ms / 1000.0
|
|
67
|
+
self._pending: list[T] = []
|
|
68
|
+
self._high_priority_count: int = 0
|
|
69
|
+
self._last_flush_monotonic: float = time.monotonic()
|
|
70
|
+
|
|
71
|
+
def add(self, prepared: T) -> bool:
|
|
72
|
+
"""Add chunk to batch. Returns True if batch should flush now."""
|
|
73
|
+
self._pending.append(prepared)
|
|
74
|
+
priority = getattr(prepared, "priority", PRIORITY_LOW)
|
|
75
|
+
if priority <= PRIORITY_HIGH:
|
|
76
|
+
self._high_priority_count += 1
|
|
77
|
+
|
|
78
|
+
now = time.monotonic()
|
|
79
|
+
if len(self._pending) >= self._max_batch_size:
|
|
80
|
+
return True
|
|
81
|
+
if self._high_priority_count > 0:
|
|
82
|
+
return True
|
|
83
|
+
if now - self._last_flush_monotonic >= self._max_batch_delay_s:
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def flush(self) -> list[T]:
|
|
88
|
+
"""Return accumulated batch and reset state."""
|
|
89
|
+
batch = list(self._pending)
|
|
90
|
+
self._pending.clear()
|
|
91
|
+
self._high_priority_count = 0
|
|
92
|
+
self._last_flush_monotonic = time.monotonic()
|
|
93
|
+
return batch
|
|
94
|
+
|
|
95
|
+
def has_pending(self) -> bool:
|
|
96
|
+
"""Return True if there are pending items."""
|
|
97
|
+
return bool(self._pending)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def pending_count(self) -> int:
|
|
101
|
+
"""Return number of pending items."""
|
|
102
|
+
return len(self._pending)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TurnEventPipeline(Generic[T]):
|
|
106
|
+
"""Bridge daemon chunk ingestion, background processing, and UI application."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
loop: asyncio.AbstractEventLoop,
|
|
111
|
+
*,
|
|
112
|
+
inbound_maxsize: int = _DEFAULT_INBOUND_MAXSIZE,
|
|
113
|
+
outbound_maxsize: int = _DEFAULT_OUTBOUND_MAXSIZE,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._loop = loop
|
|
116
|
+
self._inbound: queue.Queue[Any] = queue.Queue(maxsize=inbound_maxsize)
|
|
117
|
+
self._outbound: queue.PriorityQueue[tuple[int, int, Any]] = queue.PriorityQueue(
|
|
118
|
+
maxsize=outbound_maxsize
|
|
119
|
+
)
|
|
120
|
+
self._outbound_seq = 0
|
|
121
|
+
self._outbound_dropped = 0
|
|
122
|
+
self._stop = threading.Event()
|
|
123
|
+
self._thread: threading.Thread | None = None
|
|
124
|
+
self._processor_error: BaseException | None = None
|
|
125
|
+
|
|
126
|
+
async def feed_chunks(self, chunk_source: AsyncIterator[Any]) -> None:
|
|
127
|
+
"""Read all chunks from *chunk_source* into the inbound queue."""
|
|
128
|
+
try:
|
|
129
|
+
async for chunk in chunk_source:
|
|
130
|
+
if self._stop.is_set():
|
|
131
|
+
break
|
|
132
|
+
await asyncio.to_thread(self._inbound.put, chunk)
|
|
133
|
+
finally:
|
|
134
|
+
await asyncio.to_thread(self._inbound.put, _SENTINEL)
|
|
135
|
+
|
|
136
|
+
def start_processor(
|
|
137
|
+
self,
|
|
138
|
+
process_fn: Callable[[Any], T],
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Start the background processor thread."""
|
|
141
|
+
|
|
142
|
+
def _worker() -> None:
|
|
143
|
+
while not self._stop.is_set():
|
|
144
|
+
try:
|
|
145
|
+
item = self._inbound.get(timeout=0.25)
|
|
146
|
+
except queue.Empty:
|
|
147
|
+
continue
|
|
148
|
+
if item is _SENTINEL:
|
|
149
|
+
self._put_outbound(PRIORITY_LOW, _SENTINEL)
|
|
150
|
+
break
|
|
151
|
+
try:
|
|
152
|
+
prepared = process_fn(item)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.exception("Turn chunk processor failed")
|
|
155
|
+
self._processor_error = exc
|
|
156
|
+
self._put_outbound(PRIORITY_LOW, _SENTINEL)
|
|
157
|
+
break
|
|
158
|
+
if prepared is None:
|
|
159
|
+
continue
|
|
160
|
+
priority = getattr(prepared, "priority", PRIORITY_LOW)
|
|
161
|
+
self._put_outbound(int(priority), prepared)
|
|
162
|
+
|
|
163
|
+
self._thread = threading.Thread(
|
|
164
|
+
target=_worker,
|
|
165
|
+
name="soothe-appkit-turn-processor",
|
|
166
|
+
daemon=True,
|
|
167
|
+
)
|
|
168
|
+
self._thread.start()
|
|
169
|
+
|
|
170
|
+
def _put_outbound(self, priority: int, item: Any) -> None:
|
|
171
|
+
"""Enqueue a prepared chunk from the processor thread (thread-safe)."""
|
|
172
|
+
seq = self._outbound_seq
|
|
173
|
+
self._outbound_seq += 1
|
|
174
|
+
entry = (priority, seq, item)
|
|
175
|
+
try:
|
|
176
|
+
self._outbound.put_nowait(entry)
|
|
177
|
+
return
|
|
178
|
+
except queue.Full:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
if not self._evict_outbound_drop_candidate(incoming_priority=priority):
|
|
182
|
+
if priority <= PRIORITY_HIGH:
|
|
183
|
+
try:
|
|
184
|
+
self._outbound.put(entry, block=True, timeout=5.0)
|
|
185
|
+
return
|
|
186
|
+
except queue.Full:
|
|
187
|
+
logger.warning(
|
|
188
|
+
"Turn outbound queue still full after wait; dropping prepared chunk "
|
|
189
|
+
"(priority=%d)",
|
|
190
|
+
priority,
|
|
191
|
+
)
|
|
192
|
+
self._outbound_dropped += 1
|
|
193
|
+
return
|
|
194
|
+
logger.warning(
|
|
195
|
+
"Turn outbound queue full; dropping low-priority prepared chunk (priority=%d)",
|
|
196
|
+
priority,
|
|
197
|
+
)
|
|
198
|
+
self._outbound_dropped += 1
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
self._outbound.put_nowait(entry)
|
|
203
|
+
except queue.Full:
|
|
204
|
+
if priority <= PRIORITY_HIGH:
|
|
205
|
+
try:
|
|
206
|
+
self._outbound.put(entry, block=True, timeout=5.0)
|
|
207
|
+
except queue.Full:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"Turn outbound queue full after eviction; dropping prepared chunk "
|
|
210
|
+
"(priority=%d)",
|
|
211
|
+
priority,
|
|
212
|
+
)
|
|
213
|
+
self._outbound_dropped += 1
|
|
214
|
+
else:
|
|
215
|
+
logger.warning(
|
|
216
|
+
"Turn outbound queue full after eviction; dropping prepared chunk "
|
|
217
|
+
"(priority=%d)",
|
|
218
|
+
priority,
|
|
219
|
+
)
|
|
220
|
+
self._outbound_dropped += 1
|
|
221
|
+
|
|
222
|
+
def _evict_outbound_drop_candidate(self, *, incoming_priority: int) -> bool:
|
|
223
|
+
"""Drop one evictable chunk from the outbound queue to make room."""
|
|
224
|
+
temp: list[tuple[int, int, Any]] = []
|
|
225
|
+
drop_target: tuple[int, int, Any] | None = None
|
|
226
|
+
drop_priority = PRIORITY_CRITICAL - 1
|
|
227
|
+
|
|
228
|
+
while True:
|
|
229
|
+
try:
|
|
230
|
+
temp.append(self._outbound.get_nowait())
|
|
231
|
+
except queue.Empty:
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
for queued in temp:
|
|
235
|
+
queued_priority = queued[0]
|
|
236
|
+
if queued_priority > drop_priority:
|
|
237
|
+
drop_priority = queued_priority
|
|
238
|
+
drop_target = queued
|
|
239
|
+
|
|
240
|
+
min_evictable = PRIORITY_NORMAL if incoming_priority <= PRIORITY_HIGH else PRIORITY_LOW
|
|
241
|
+
if drop_target is None or drop_priority < min_evictable:
|
|
242
|
+
for queued in temp:
|
|
243
|
+
self._outbound.put_nowait(queued)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
self._outbound_dropped += 1
|
|
247
|
+
if self._outbound_dropped == 1 or self._outbound_dropped % 500 == 0:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Turn outbound queue overflow: evicted buffered chunk (priority=%d, dropped=%d)",
|
|
250
|
+
drop_priority,
|
|
251
|
+
self._outbound_dropped,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for queued in temp:
|
|
255
|
+
if queued is drop_target:
|
|
256
|
+
continue
|
|
257
|
+
self._outbound.put_nowait(queued)
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
async def iter_prepared(self) -> AsyncIterator[T]:
|
|
261
|
+
"""Yield prepared chunk plans until the stream ends."""
|
|
262
|
+
while True:
|
|
263
|
+
_priority, _seq, item = await asyncio.to_thread(self._outbound.get)
|
|
264
|
+
if item is _SENTINEL:
|
|
265
|
+
if self._processor_error is not None:
|
|
266
|
+
raise self._processor_error
|
|
267
|
+
break
|
|
268
|
+
yield item
|
|
269
|
+
|
|
270
|
+
def shutdown(self) -> None:
|
|
271
|
+
"""Signal the processor thread to stop (best-effort)."""
|
|
272
|
+
self._stop.set()
|
|
273
|
+
if self._thread is not None and self._thread.is_alive():
|
|
274
|
+
self._thread.join(timeout=2.0)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _record_apply_latency(prepared: Any, latency: SupportsTurnLatency | None) -> None:
|
|
278
|
+
"""Record first-chunk and synthesis-visible timings."""
|
|
279
|
+
if latency is None or getattr(prepared, "skip", False):
|
|
280
|
+
return
|
|
281
|
+
latency.record_first_chunk()
|
|
282
|
+
if getattr(prepared, "mode", None) == "messages":
|
|
283
|
+
message = getattr(prepared, "normalized_message", None)
|
|
284
|
+
if message is not None and assistant_output_phase(message) == "goal_completion":
|
|
285
|
+
latency.record_goal_completion()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def run_turn_pipeline(
|
|
289
|
+
chunk_source: AsyncIterator[Any],
|
|
290
|
+
process_fn: Callable[[Any], T],
|
|
291
|
+
apply_fn: Callable[[T], Any],
|
|
292
|
+
*,
|
|
293
|
+
batch_size: int = _DEFAULT_BATCH_SIZE,
|
|
294
|
+
batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
|
|
295
|
+
batching_enabled: bool = True,
|
|
296
|
+
latency_stats: SupportsTurnLatency | None = None,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Run reader, processor thread, and applier coroutine to completion."""
|
|
299
|
+
loop = asyncio.get_running_loop()
|
|
300
|
+
if latency_stats is not None and latency_stats.turn_start_monotonic <= 0:
|
|
301
|
+
latency_stats.turn_start_monotonic = time.monotonic()
|
|
302
|
+
pipeline: TurnEventPipeline[T] = TurnEventPipeline(loop)
|
|
303
|
+
pipeline.start_processor(process_fn)
|
|
304
|
+
|
|
305
|
+
async def _apply_with_latency(prepared: T) -> None:
|
|
306
|
+
_record_apply_latency(prepared, latency_stats)
|
|
307
|
+
await apply_fn(prepared)
|
|
308
|
+
|
|
309
|
+
if batching_enabled:
|
|
310
|
+
batcher: TurnApplyBatcher[T] = TurnApplyBatcher(
|
|
311
|
+
max_batch_size=batch_size,
|
|
312
|
+
max_batch_delay_ms=batch_delay_ms,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def _apply_batched() -> None:
|
|
316
|
+
async for prepared in pipeline.iter_prepared():
|
|
317
|
+
if batcher.add(prepared):
|
|
318
|
+
batch = batcher.flush()
|
|
319
|
+
for p in batch:
|
|
320
|
+
await _apply_with_latency(p)
|
|
321
|
+
await asyncio.sleep(0)
|
|
322
|
+
|
|
323
|
+
if batcher.has_pending():
|
|
324
|
+
for p in batcher.flush():
|
|
325
|
+
await _apply_with_latency(p)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
await asyncio.gather(
|
|
329
|
+
pipeline.feed_chunks(chunk_source),
|
|
330
|
+
_apply_batched(),
|
|
331
|
+
)
|
|
332
|
+
finally:
|
|
333
|
+
pipeline.shutdown()
|
|
334
|
+
else:
|
|
335
|
+
|
|
336
|
+
async def _apply_all() -> None:
|
|
337
|
+
async for prepared in pipeline.iter_prepared():
|
|
338
|
+
await _apply_with_latency(prepared)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
await asyncio.gather(
|
|
342
|
+
pipeline.feed_chunks(chunk_source),
|
|
343
|
+
_apply_all(),
|
|
344
|
+
)
|
|
345
|
+
finally:
|
|
346
|
+
pipeline.shutdown()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
__all__ = [
|
|
350
|
+
"PRIORITY_CRITICAL",
|
|
351
|
+
"PRIORITY_HIGH",
|
|
352
|
+
"PRIORITY_LOW",
|
|
353
|
+
"PRIORITY_NORMAL",
|
|
354
|
+
"SupportsTurnLatency",
|
|
355
|
+
"TurnApplyBatcher",
|
|
356
|
+
"TurnEventPipeline",
|
|
357
|
+
"run_turn_pipeline",
|
|
358
|
+
"_DEFAULT_BATCH_SIZE",
|
|
359
|
+
"_DEFAULT_BATCH_DELAY_MS",
|
|
360
|
+
"_DEFAULT_INBOUND_MAXSIZE",
|
|
361
|
+
"_DEFAULT_OUTBOUND_MAXSIZE",
|
|
362
|
+
]
|