pulse-orchestrator 0.3.0__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.
- pulse/__init__.py +3 -0
- pulse/__main__.py +3 -0
- pulse/acp/__init__.py +3 -0
- pulse/acp/capabilities.py +27 -0
- pulse/acp/client.py +234 -0
- pulse/acp/reply_clock.py +44 -0
- pulse/acp/session_runner.py +543 -0
- pulse/acp/step_turns.py +206 -0
- pulse/acp/watched_prompts.py +62 -0
- pulse/api/__init__.py +3 -0
- pulse/api/app.py +39 -0
- pulse/api/browser_guard.py +44 -0
- pulse/api/config_routes.py +97 -0
- pulse/api/deps.py +28 -0
- pulse/api/history_routes.py +104 -0
- pulse/api/run_routes.py +143 -0
- pulse/api/settings_routes.py +239 -0
- pulse/api/system_routes.py +67 -0
- pulse/api/token_guard.py +52 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/daemon_command.py +71 -0
- pulse/cli/main.py +205 -0
- pulse/client/__init__.py +3 -0
- pulse/client/active_runs.py +39 -0
- pulse/client/daemon_process.py +74 -0
- pulse/client/engine_client.py +229 -0
- pulse/client/protocol.py +42 -0
- pulse/client/run_session.py +344 -0
- pulse/client/runtime_login.py +25 -0
- pulse/config/__init__.py +56 -0
- pulse/config/agent_profiles.py +34 -0
- pulse/config/agents.py +106 -0
- pulse/config/commands.py +50 -0
- pulse/config/global_config.py +264 -0
- pulse/config/model_catalog.py +68 -0
- pulse/config/onboarding.py +63 -0
- pulse/config/project_config.py +358 -0
- pulse/config/project_settings.py +202 -0
- pulse/config/ready.py +14 -0
- pulse/config/resolved.py +309 -0
- pulse/daemon/__init__.py +1 -0
- pulse/daemon/__main__.py +4 -0
- pulse/daemon/adapters.py +217 -0
- pulse/daemon/events.py +150 -0
- pulse/daemon/logs.py +32 -0
- pulse/daemon/runs.py +448 -0
- pulse/daemon/server.py +220 -0
- pulse/doctor/__init__.py +13 -0
- pulse/doctor/checks.py +126 -0
- pulse/doctor/report.py +124 -0
- pulse/domain/__init__.py +0 -0
- pulse/domain/branch_name.py +29 -0
- pulse/domain/control.py +56 -0
- pulse/domain/git_options.py +67 -0
- pulse/domain/outcome.py +201 -0
- pulse/domain/ports.py +574 -0
- pulse/domain/run.py +519 -0
- pulse/domain/task_brief.py +34 -0
- pulse/domain/workflow.py +154 -0
- pulse/history/__init__.py +10 -0
- pulse/history/catalog.py +130 -0
- pulse/history/events.py +191 -0
- pulse/history/log_wording.py +19 -0
- pulse/history/models.py +130 -0
- pulse/history/prompt_detail.py +224 -0
- pulse/history/run_lines.py +59 -0
- pulse/history/run_log.py +161 -0
- pulse/history/session_log.py +267 -0
- pulse/history/status_labels.py +78 -0
- pulse/history/tool_classify.py +48 -0
- pulse/history/tool_detail.py +537 -0
- pulse/mcp/__init__.py +1 -0
- pulse/mcp/ask_bridge.py +109 -0
- pulse/mcp/ask_protocol.py +126 -0
- pulse/mcp/ask_stdio.py +128 -0
- pulse/mcp/channel.py +30 -0
- pulse/mcp/config.py +14 -0
- pulse/mcp/http_bridge.py +166 -0
- pulse/orchestrator/__init__.py +4 -0
- pulse/orchestrator/agent_text.py +197 -0
- pulse/orchestrator/delivery.py +115 -0
- pulse/orchestrator/engine.py +620 -0
- pulse/orchestrator/fanout.py +84 -0
- pulse/orchestrator/loops.py +47 -0
- pulse/orchestrator/menus.py +119 -0
- pulse/orchestrator/render.py +46 -0
- pulse/orchestrator/turn.py +67 -0
- pulse/orchestrator/waits.py +33 -0
- pulse/orchestrator/wiring.py +67 -0
- pulse/orchestrator/wording.py +136 -0
- pulse/orchestrator/worktree.py +76 -0
- pulse/replies/__init__.py +63 -0
- pulse/replies/acp_payloads.py +208 -0
- pulse/replies/approval.py +106 -0
- pulse/replies/ask_question.py +173 -0
- pulse/replies/permission.py +127 -0
- pulse/replies/session_models.py +140 -0
- pulse/replies/text_questionnaire.py +265 -0
- pulse/replies/wording.py +25 -0
- pulse/runtimes/__init__.py +83 -0
- pulse/runtimes/base.py +228 -0
- pulse/runtimes/claude.py +93 -0
- pulse/runtimes/codex.py +81 -0
- pulse/runtimes/cursor.py +51 -0
- pulse/store/__init__.py +3 -0
- pulse/store/agent_settings.py +109 -0
- pulse/store/codec.py +16 -0
- pulse/store/db.py +418 -0
- pulse/store/factory/__init__.py +522 -0
- pulse/store/factory/agents/codebase-docs/AGENT.md +980 -0
- pulse/store/factory/agents/codebase-docs/PHASE.md +27 -0
- pulse/store/factory/agents/codebase-explore/AGENT.md +980 -0
- pulse/store/factory/agents/codebase-explore/PHASE.md +56 -0
- pulse/store/factory/agents/implement/AGENT.md +447 -0
- pulse/store/factory/agents/implement/FIX.md +76 -0
- pulse/store/factory/agents/implement/PHASE.md +37 -0
- pulse/store/factory/agents/prd/AGENT.md +763 -0
- pulse/store/factory/agents/prd/PHASE.md +31 -0
- pulse/store/factory/agents/quick-implement/AGENT.md +654 -0
- pulse/store/factory/agents/quick-implement/PHASE.md +32 -0
- pulse/store/factory/agents/quick-plan/AGENT.md +594 -0
- pulse/store/factory/agents/quick-plan/PHASE.md +78 -0
- pulse/store/factory/agents/review/AGENT.md +1088 -0
- pulse/store/factory/agents/review/PHASE.md +93 -0
- pulse/store/factory/agents/review/STANDALONE.md +117 -0
- pulse/store/factory/agents/review-fix/AGENT.md +625 -0
- pulse/store/factory/agents/review-fix/PHASE.md +106 -0
- pulse/store/factory/agents/review-fix/STANDALONE.md +124 -0
- pulse/store/factory/agents/setup-artifacts/AGENT.md +525 -0
- pulse/store/factory/agents/setup-artifacts/PHASE.md +19 -0
- pulse/store/factory/agents/setup-interview/AGENT.md +525 -0
- pulse/store/factory/agents/setup-interview/PHASE.md +56 -0
- pulse/store/factory/agents/tasks/AGENT.md +846 -0
- pulse/store/factory/agents/tasks/PHASE.md +47 -0
- pulse/store/factory/agents/techspec/AGENT.md +577 -0
- pulse/store/factory/agents/techspec/PHASE.md +28 -0
- pulse/store/factory/agents/verify/AGENT.md +368 -0
- pulse/store/factory/agents/verify/PHASE.md +118 -0
- pulse/store/factory/pull_request.md +20 -0
- pulse/store/factory/shared/approval-skip.md +27 -0
- pulse/store/factory/shared/approval.md +18 -0
- pulse/store/factory/shared/clarify.md +74 -0
- pulse/store/factory/shared/handoff.md +16 -0
- pulse/store/factory/shared/isolation-reuse.md +4 -0
- pulse/store/factory/shared/isolation.md +7 -0
- pulse/store/factory/shared/output-language.md +20 -0
- pulse/store/factory/shared/review-close.md +25 -0
- pulse/store/paths.py +119 -0
- pulse/store/runs.py +558 -0
- pulse/store/sessions.py +98 -0
- pulse/store/workflow_defs.py +198 -0
- pulse/tui/__init__.py +11 -0
- pulse/tui/adapters/__init__.py +6 -0
- pulse/tui/adapters/cockpit_sink.py +505 -0
- pulse/tui/adapters/prompt_bridge.py +431 -0
- pulse/tui/app.py +79 -0
- pulse/tui/artifact_tab.py +159 -0
- pulse/tui/artifacts.py +106 -0
- pulse/tui/brand.py +10 -0
- pulse/tui/cursor_store.py +199 -0
- pulse/tui/engine.py +454 -0
- pulse/tui/history_rows.py +89 -0
- pulse/tui/history_theme.py +81 -0
- pulse/tui/home_theme.py +340 -0
- pulse/tui/hydrate.py +236 -0
- pulse/tui/key_hints.py +16 -0
- pulse/tui/menu_rows.py +65 -0
- pulse/tui/palette.py +12 -0
- pulse/tui/screens/__init__.py +8 -0
- pulse/tui/screens/branch_name.py +80 -0
- pulse/tui/screens/cockpit.py +483 -0
- pulse/tui/screens/confirm.py +62 -0
- pulse/tui/screens/history.py +311 -0
- pulse/tui/screens/history_detail.py +273 -0
- pulse/tui/screens/home.py +479 -0
- pulse/tui/screens/navigation.py +17 -0
- pulse/tui/screens/onboarding.py +311 -0
- pulse/tui/screens/resume_choice.py +60 -0
- pulse/tui/screens/settings.py +488 -0
- pulse/tui/settings_rows.py +65 -0
- pulse/tui/settings_theme.py +72 -0
- pulse/tui/state.py +635 -0
- pulse/tui/strings.py +345 -0
- pulse/tui/theme.py +501 -0
- pulse/tui/widgets/__init__.py +9 -0
- pulse/tui/widgets/app_footer.py +58 -0
- pulse/tui/widgets/app_header.py +48 -0
- pulse/tui/widgets/artifact_pane.py +241 -0
- pulse/tui/widgets/file_mention.py +100 -0
- pulse/tui/widgets/log_stream.py +347 -0
- pulse/tui/widgets/menu_list.py +73 -0
- pulse/tui/widgets/options.py +13 -0
- pulse/tui/widgets/options_toggle.py +63 -0
- pulse/tui/widgets/phase_sidebar.py +211 -0
- pulse/tui/widgets/prompt_dock.py +179 -0
- pulse/tui/widgets/task_composer.py +131 -0
- pulse/tui/widgets/task_form.py +238 -0
- pulse/vcs/__init__.py +258 -0
- pulse/vcs/changes.py +110 -0
- pulse/vcs/publish.py +109 -0
- pulse/vcs/workspace.py +102 -0
- pulse_orchestrator-0.3.0.dist-info/METADATA +444 -0
- pulse_orchestrator-0.3.0.dist-info/RECORD +207 -0
- pulse_orchestrator-0.3.0.dist-info/WHEEL +5 -0
- pulse_orchestrator-0.3.0.dist-info/entry_points.txt +4 -0
- pulse_orchestrator-0.3.0.dist-info/licenses/LICENSE +21 -0
- pulse_orchestrator-0.3.0.dist-info/top_level.txt +1 -0
pulse/__init__.py
ADDED
pulse/__main__.py
ADDED
pulse/acp/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pulse import __version__
|
|
4
|
+
|
|
5
|
+
# Pulse does not run terminals for the agent; permission requests whose kind
|
|
6
|
+
# names a terminal or shell are rejected unless this changes. `execute`, the
|
|
7
|
+
# agent running its own shell, follows the step's policy instead.
|
|
8
|
+
CLIENT_RUNS_TERMINAL = False
|
|
9
|
+
|
|
10
|
+
PULSE_CLIENT_CAPABILITIES: dict[str, object] = {
|
|
11
|
+
"fs": {
|
|
12
|
+
"readTextFile": False,
|
|
13
|
+
"writeTextFile": False,
|
|
14
|
+
},
|
|
15
|
+
"terminal": CLIENT_RUNS_TERMINAL,
|
|
16
|
+
"_meta": {
|
|
17
|
+
"cursor": {
|
|
18
|
+
"interactiveExtensions": ["cursor/ask_question", "cursor/create_plan"],
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
PULSE_CLIENT_INFO: dict[str, str] = {
|
|
24
|
+
"name": "pulse",
|
|
25
|
+
"title": "Pulse",
|
|
26
|
+
"version": __version__,
|
|
27
|
+
}
|
pulse/acp/client.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from queue import Empty, Queue
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pulse.acp.capabilities import PULSE_CLIENT_CAPABILITIES, PULSE_CLIENT_INFO
|
|
12
|
+
from pulse.replies.session_models import extract_model_labels
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AcpProbeResult:
|
|
17
|
+
success: bool
|
|
18
|
+
message: str
|
|
19
|
+
response_text: str = ""
|
|
20
|
+
available_models: list[str] = field(default_factory=list)
|
|
21
|
+
error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AcpClient:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
command: list[str],
|
|
28
|
+
*,
|
|
29
|
+
cwd: str,
|
|
30
|
+
timeout_seconds: float = 60.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._command = command
|
|
33
|
+
self._cwd = cwd
|
|
34
|
+
self._timeout_seconds = timeout_seconds
|
|
35
|
+
self._process: subprocess.Popen[str] | None = None
|
|
36
|
+
self._responses: dict[int, dict[str, Any]] = {}
|
|
37
|
+
self._response_queue: Queue[int] = Queue()
|
|
38
|
+
self._reader: threading.Thread | None = None
|
|
39
|
+
self._next_id = 1
|
|
40
|
+
self._session_updates: list[dict[str, Any]] = []
|
|
41
|
+
|
|
42
|
+
def start(self) -> None:
|
|
43
|
+
# argv list from the user's effective config, never from agent output.
|
|
44
|
+
self._process = subprocess.Popen( # noqa: S603
|
|
45
|
+
self._command,
|
|
46
|
+
cwd=self._cwd,
|
|
47
|
+
stdin=subprocess.PIPE,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.DEVNULL,
|
|
50
|
+
text=True,
|
|
51
|
+
bufsize=1,
|
|
52
|
+
)
|
|
53
|
+
self._reader = threading.Thread(target=self._read_stdout, daemon=True)
|
|
54
|
+
self._reader.start()
|
|
55
|
+
|
|
56
|
+
def close(self) -> None:
|
|
57
|
+
if self._process is None:
|
|
58
|
+
return
|
|
59
|
+
if self._process.stdin:
|
|
60
|
+
try:
|
|
61
|
+
self._process.stdin.close()
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
try:
|
|
65
|
+
self._process.wait(timeout=5)
|
|
66
|
+
except subprocess.TimeoutExpired:
|
|
67
|
+
self._process.terminate()
|
|
68
|
+
self._process = None
|
|
69
|
+
|
|
70
|
+
def _read_stdout(self) -> None:
|
|
71
|
+
if self._process is None or self._process.stdout is None:
|
|
72
|
+
return
|
|
73
|
+
for line in self._process.stdout:
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
message = json.loads(line)
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
if "method" in message and "id" not in message:
|
|
82
|
+
if message.get("method") == "session/update":
|
|
83
|
+
params = message.get("params", {})
|
|
84
|
+
self._session_updates.append(params)
|
|
85
|
+
continue
|
|
86
|
+
message_id = message.get("id")
|
|
87
|
+
if isinstance(message_id, int):
|
|
88
|
+
self._responses[message_id] = message
|
|
89
|
+
self._response_queue.put(message_id)
|
|
90
|
+
|
|
91
|
+
def _send(self, payload: dict[str, Any]) -> None:
|
|
92
|
+
if self._process is None or self._process.stdin is None:
|
|
93
|
+
raise RuntimeError("ACP process not started")
|
|
94
|
+
self._process.stdin.write(json.dumps(payload) + "\n")
|
|
95
|
+
self._process.stdin.flush()
|
|
96
|
+
|
|
97
|
+
def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
request_id = self._next_id
|
|
99
|
+
self._next_id += 1
|
|
100
|
+
self._send(
|
|
101
|
+
{
|
|
102
|
+
"jsonrpc": "2.0",
|
|
103
|
+
"id": request_id,
|
|
104
|
+
"method": method,
|
|
105
|
+
"params": params,
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
response_id = self._response_queue.get(timeout=self._timeout_seconds)
|
|
110
|
+
except Empty as error:
|
|
111
|
+
raise TimeoutError(f"timed out waiting for a response to `{method}`") from error
|
|
112
|
+
|
|
113
|
+
response = self._responses.pop(response_id, {})
|
|
114
|
+
if "error" in response:
|
|
115
|
+
failure = response["error"]
|
|
116
|
+
raise RuntimeError(failure.get("message", "unknown ACP error"))
|
|
117
|
+
return response.get("result", {})
|
|
118
|
+
|
|
119
|
+
def initialize(self) -> dict[str, Any]:
|
|
120
|
+
result = self._request(
|
|
121
|
+
"initialize",
|
|
122
|
+
{
|
|
123
|
+
"protocolVersion": 1,
|
|
124
|
+
"clientCapabilities": PULSE_CLIENT_CAPABILITIES,
|
|
125
|
+
"clientInfo": PULSE_CLIENT_INFO,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def new_session(self, cwd: str) -> dict[str, Any]:
|
|
132
|
+
return self._request("session/new", {"cwd": cwd, "mcpServers": []})
|
|
133
|
+
|
|
134
|
+
def prompt(self, session_id: str, text: str) -> dict[str, Any]:
|
|
135
|
+
return self._request(
|
|
136
|
+
"session/prompt",
|
|
137
|
+
{
|
|
138
|
+
"sessionId": session_id,
|
|
139
|
+
"prompt": [{"type": "text", "text": text}],
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def collect_message_text(self) -> str:
|
|
144
|
+
chunks: list[str] = []
|
|
145
|
+
for params in self._session_updates:
|
|
146
|
+
update = params.get("update", {})
|
|
147
|
+
if update.get("sessionUpdate") != "agent_message_chunk":
|
|
148
|
+
continue
|
|
149
|
+
content = update.get("content", {})
|
|
150
|
+
if content.get("type") == "text":
|
|
151
|
+
chunks.append(str(content.get("text", "")))
|
|
152
|
+
return "".join(chunks)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def fetch_acp_available_models(
|
|
156
|
+
*,
|
|
157
|
+
command: list[str] | None = None,
|
|
158
|
+
cwd: str,
|
|
159
|
+
timeout_seconds: float = 30.0,
|
|
160
|
+
) -> list[str]:
|
|
161
|
+
acp_command = command or ["agent", "acp"]
|
|
162
|
+
client = AcpClient(acp_command, cwd=cwd, timeout_seconds=timeout_seconds)
|
|
163
|
+
try:
|
|
164
|
+
client.start()
|
|
165
|
+
client.initialize()
|
|
166
|
+
session = client.new_session(cwd)
|
|
167
|
+
return extract_model_labels(session)
|
|
168
|
+
except (RuntimeError, TimeoutError, OSError):
|
|
169
|
+
return []
|
|
170
|
+
finally:
|
|
171
|
+
client.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _extract_models(session_result: dict[str, Any]) -> list[str]:
|
|
175
|
+
return extract_model_labels(session_result)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def probe_acp_connection(
|
|
179
|
+
*,
|
|
180
|
+
command: list[str] | None = None,
|
|
181
|
+
cwd: str,
|
|
182
|
+
prompt_text: str = "Say hello",
|
|
183
|
+
timeout_seconds: float = 60.0,
|
|
184
|
+
on_status: Callable[[str], None] | None = None,
|
|
185
|
+
) -> AcpProbeResult:
|
|
186
|
+
acp_command = command or ["agent", "acp"]
|
|
187
|
+
client = AcpClient(acp_command, cwd=cwd, timeout_seconds=timeout_seconds)
|
|
188
|
+
|
|
189
|
+
def status(message: str) -> None:
|
|
190
|
+
if on_status:
|
|
191
|
+
on_status(message)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
status("Starting the ACP agent…")
|
|
195
|
+
client.start()
|
|
196
|
+
status("Handshake initialize…")
|
|
197
|
+
client.initialize()
|
|
198
|
+
status("Creating a session…")
|
|
199
|
+
session = client.new_session(cwd)
|
|
200
|
+
session_id = session.get("sessionId")
|
|
201
|
+
if not session_id:
|
|
202
|
+
return AcpProbeResult(
|
|
203
|
+
success=False,
|
|
204
|
+
message="ACP session returned no sessionId",
|
|
205
|
+
error="missing_session_id",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
models = _extract_models(session)
|
|
209
|
+
status(f'Sending the test prompt: "{prompt_text}"…')
|
|
210
|
+
client.prompt(str(session_id), prompt_text)
|
|
211
|
+
response_text = client.collect_message_text().strip()
|
|
212
|
+
|
|
213
|
+
if not response_text:
|
|
214
|
+
return AcpProbeResult(
|
|
215
|
+
success=False,
|
|
216
|
+
message="ACP replied with no message content",
|
|
217
|
+
available_models=models,
|
|
218
|
+
error="empty_response",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return AcpProbeResult(
|
|
222
|
+
success=True,
|
|
223
|
+
message="ACP connection verified",
|
|
224
|
+
response_text=response_text,
|
|
225
|
+
available_models=models,
|
|
226
|
+
)
|
|
227
|
+
except (RuntimeError, TimeoutError, OSError) as error:
|
|
228
|
+
return AcpProbeResult(
|
|
229
|
+
success=False,
|
|
230
|
+
message="ACP test failed",
|
|
231
|
+
error=str(error),
|
|
232
|
+
)
|
|
233
|
+
finally:
|
|
234
|
+
client.close()
|
pulse/acp/reply_clock.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""How long the runner waits for the agent's reply to one JSON-RPC request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
CANCEL_GRACE_SECONDS = 10.0
|
|
9
|
+
|
|
10
|
+
# Requests whose deadline measures silence: a working agent keeps talking.
|
|
11
|
+
IDLE_CLOCK_METHODS = frozenset({"session/prompt"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ReplyClock:
|
|
16
|
+
"""When a request stops waiting for its reply.
|
|
17
|
+
|
|
18
|
+
`session/prompt` measures agent silence (`idle`): the deadline follows the last
|
|
19
|
+
sign of activity. Other requests keep a fixed deadline. A cancelled turn gets
|
|
20
|
+
a short grace period instead, so the agent can close it on its own.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
limit: float
|
|
24
|
+
idle: bool
|
|
25
|
+
deadline: float
|
|
26
|
+
cancelling: bool = False
|
|
27
|
+
|
|
28
|
+
def follow(self, activity: float) -> None:
|
|
29
|
+
if self.idle and not self.cancelling:
|
|
30
|
+
self.deadline = activity + self.limit
|
|
31
|
+
|
|
32
|
+
def expired(self) -> bool:
|
|
33
|
+
return time.monotonic() >= self.deadline
|
|
34
|
+
|
|
35
|
+
def remaining(self) -> float:
|
|
36
|
+
return max(0.0, self.deadline - time.monotonic())
|
|
37
|
+
|
|
38
|
+
def start_cancel(self) -> None:
|
|
39
|
+
self.cancelling = True
|
|
40
|
+
self.deadline = time.monotonic() + CANCEL_GRACE_SECONDS
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def for_request(cls, method: str, limit: float, started: float) -> ReplyClock:
|
|
44
|
+
return cls(limit=limit, idle=method in IDLE_CLOCK_METHODS, deadline=started + limit)
|