yee88 0.1.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.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
takopi/runner.py
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
"""Runner protocol and shared runner definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import AsyncIterator, Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol, cast
|
|
11
|
+
from weakref import WeakValueDictionary
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
from .logging import get_logger, log_pipeline
|
|
16
|
+
from .model import (
|
|
17
|
+
Action,
|
|
18
|
+
ActionEvent,
|
|
19
|
+
CompletedEvent,
|
|
20
|
+
EngineId,
|
|
21
|
+
ResumeToken,
|
|
22
|
+
StartedEvent,
|
|
23
|
+
TakopiEvent,
|
|
24
|
+
)
|
|
25
|
+
from .utils.paths import get_run_base_dir
|
|
26
|
+
from .utils.streams import drain_stderr, iter_bytes_lines
|
|
27
|
+
from .utils.subprocess import manage_subprocess
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResumeTokenMixin:
|
|
31
|
+
engine: EngineId
|
|
32
|
+
resume_re: re.Pattern[str]
|
|
33
|
+
|
|
34
|
+
def format_resume(self, token: ResumeToken) -> str:
|
|
35
|
+
if token.engine != self.engine:
|
|
36
|
+
raise RuntimeError(f"resume token is for engine {token.engine!r}")
|
|
37
|
+
return f"`{self.engine} resume {token.value}`"
|
|
38
|
+
|
|
39
|
+
def is_resume_line(self, line: str) -> bool:
|
|
40
|
+
return bool(self.resume_re.match(line))
|
|
41
|
+
|
|
42
|
+
def extract_resume(self, text: str | None) -> ResumeToken | None:
|
|
43
|
+
if not text:
|
|
44
|
+
return None
|
|
45
|
+
found: str | None = None
|
|
46
|
+
for match in self.resume_re.finditer(text):
|
|
47
|
+
token = match.group("token")
|
|
48
|
+
if token:
|
|
49
|
+
found = token
|
|
50
|
+
if not found:
|
|
51
|
+
return None
|
|
52
|
+
return ResumeToken(engine=self.engine, value=found)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SessionLockMixin:
|
|
56
|
+
engine: EngineId
|
|
57
|
+
session_locks: WeakValueDictionary[str, anyio.Semaphore] | None = None
|
|
58
|
+
|
|
59
|
+
def lock_for(self, token: ResumeToken) -> anyio.Semaphore:
|
|
60
|
+
locks = self.session_locks
|
|
61
|
+
if locks is None:
|
|
62
|
+
locks = WeakValueDictionary()
|
|
63
|
+
self.session_locks = locks
|
|
64
|
+
key = f"{token.engine}:{token.value}"
|
|
65
|
+
lock = locks.get(key)
|
|
66
|
+
if lock is None:
|
|
67
|
+
lock = anyio.Semaphore(1)
|
|
68
|
+
locks[key] = lock
|
|
69
|
+
return lock
|
|
70
|
+
|
|
71
|
+
async def run_with_resume_lock(
|
|
72
|
+
self,
|
|
73
|
+
prompt: str,
|
|
74
|
+
resume: ResumeToken | None,
|
|
75
|
+
run_fn: Callable[[str, ResumeToken | None], AsyncIterator[TakopiEvent]],
|
|
76
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
77
|
+
resume_token = resume
|
|
78
|
+
if resume_token is not None and resume_token.engine != self.engine:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"resume token is for engine {resume_token.engine!r}, not {self.engine!r}"
|
|
81
|
+
)
|
|
82
|
+
if resume_token is None:
|
|
83
|
+
async for evt in run_fn(prompt, resume_token):
|
|
84
|
+
yield evt
|
|
85
|
+
return
|
|
86
|
+
lock = self.lock_for(resume_token)
|
|
87
|
+
async with lock:
|
|
88
|
+
async for evt in run_fn(prompt, resume_token):
|
|
89
|
+
yield evt
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BaseRunner(SessionLockMixin):
|
|
93
|
+
engine: EngineId
|
|
94
|
+
|
|
95
|
+
def run(
|
|
96
|
+
self, prompt: str, resume: ResumeToken | None
|
|
97
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
98
|
+
return self.run_locked(prompt, resume)
|
|
99
|
+
|
|
100
|
+
async def run_locked(
|
|
101
|
+
self, prompt: str, resume: ResumeToken | None
|
|
102
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
103
|
+
if resume is not None:
|
|
104
|
+
async for evt in self.run_with_resume_lock(prompt, resume, self.run_impl):
|
|
105
|
+
yield evt
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
lock: anyio.Semaphore | None = None
|
|
109
|
+
acquired = False
|
|
110
|
+
try:
|
|
111
|
+
async for evt in self.run_impl(prompt, None):
|
|
112
|
+
if lock is None and isinstance(evt, StartedEvent):
|
|
113
|
+
lock = self.lock_for(evt.resume)
|
|
114
|
+
await lock.acquire()
|
|
115
|
+
acquired = True
|
|
116
|
+
yield evt
|
|
117
|
+
finally:
|
|
118
|
+
if acquired and lock is not None:
|
|
119
|
+
lock.release()
|
|
120
|
+
|
|
121
|
+
async def run_impl(
|
|
122
|
+
self, prompt: str, resume: ResumeToken | None
|
|
123
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
124
|
+
if False:
|
|
125
|
+
yield # pragma: no cover
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class JsonlRunState:
|
|
131
|
+
note_seq: int = 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(slots=True)
|
|
135
|
+
class JsonlStreamState:
|
|
136
|
+
expected_session: ResumeToken | None
|
|
137
|
+
found_session: ResumeToken | None = None
|
|
138
|
+
did_emit_completed: bool = False
|
|
139
|
+
ignored_after_completed: bool = False
|
|
140
|
+
jsonl_seq: int = 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class JsonlSubprocessRunner(BaseRunner):
|
|
144
|
+
def get_logger(self) -> Any:
|
|
145
|
+
return getattr(self, "logger", get_logger(__name__))
|
|
146
|
+
|
|
147
|
+
def command(self) -> str:
|
|
148
|
+
raise NotImplementedError
|
|
149
|
+
|
|
150
|
+
def tag(self) -> str:
|
|
151
|
+
return str(self.engine)
|
|
152
|
+
|
|
153
|
+
def build_args(
|
|
154
|
+
self,
|
|
155
|
+
prompt: str,
|
|
156
|
+
resume: ResumeToken | None,
|
|
157
|
+
*,
|
|
158
|
+
state: Any,
|
|
159
|
+
) -> list[str]:
|
|
160
|
+
raise NotImplementedError
|
|
161
|
+
|
|
162
|
+
def stdin_payload(
|
|
163
|
+
self,
|
|
164
|
+
prompt: str,
|
|
165
|
+
resume: ResumeToken | None,
|
|
166
|
+
*,
|
|
167
|
+
state: Any,
|
|
168
|
+
) -> bytes | None:
|
|
169
|
+
return prompt.encode()
|
|
170
|
+
|
|
171
|
+
def env(self, *, state: Any) -> dict[str, str] | None:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def new_state(self, prompt: str, resume: ResumeToken | None) -> Any:
|
|
175
|
+
return JsonlRunState()
|
|
176
|
+
|
|
177
|
+
def start_run(
|
|
178
|
+
self,
|
|
179
|
+
prompt: str,
|
|
180
|
+
resume: ResumeToken | None,
|
|
181
|
+
*,
|
|
182
|
+
state: Any,
|
|
183
|
+
) -> None:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def pipes_error_message(self) -> str:
|
|
187
|
+
return f"{self.tag()} failed to open subprocess pipes"
|
|
188
|
+
|
|
189
|
+
def next_note_id(self, state: Any) -> str:
|
|
190
|
+
try:
|
|
191
|
+
note_seq = state.note_seq
|
|
192
|
+
except AttributeError as exc:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
"state must define note_seq or override next_note_id"
|
|
195
|
+
) from exc
|
|
196
|
+
state.note_seq = note_seq + 1
|
|
197
|
+
return f"{self.tag()}.note.{state.note_seq}"
|
|
198
|
+
|
|
199
|
+
def note_event(
|
|
200
|
+
self,
|
|
201
|
+
message: str,
|
|
202
|
+
*,
|
|
203
|
+
state: Any,
|
|
204
|
+
ok: bool = False,
|
|
205
|
+
detail: dict[str, Any] | None = None,
|
|
206
|
+
) -> TakopiEvent:
|
|
207
|
+
note_id = self.next_note_id(state)
|
|
208
|
+
action = Action(
|
|
209
|
+
id=note_id,
|
|
210
|
+
kind="warning",
|
|
211
|
+
title=message,
|
|
212
|
+
detail=detail or {},
|
|
213
|
+
)
|
|
214
|
+
return ActionEvent(
|
|
215
|
+
engine=self.engine,
|
|
216
|
+
action=action,
|
|
217
|
+
phase="completed",
|
|
218
|
+
ok=ok,
|
|
219
|
+
message=message,
|
|
220
|
+
level="info" if ok else "warning",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def invalid_json_events(
|
|
224
|
+
self,
|
|
225
|
+
*,
|
|
226
|
+
raw: str,
|
|
227
|
+
line: str,
|
|
228
|
+
state: Any,
|
|
229
|
+
) -> list[TakopiEvent]:
|
|
230
|
+
message = f"invalid JSON from {self.tag()}; ignoring line"
|
|
231
|
+
return [self.note_event(message, state=state, detail={"line": line})]
|
|
232
|
+
|
|
233
|
+
def decode_jsonl(self, *, line: bytes) -> Any | None:
|
|
234
|
+
text = line.decode("utf-8", errors="replace")
|
|
235
|
+
try:
|
|
236
|
+
return cast(dict[str, Any], json.loads(text))
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
async def iter_json_lines(
|
|
241
|
+
self,
|
|
242
|
+
stream: Any,
|
|
243
|
+
) -> AsyncIterator[bytes]:
|
|
244
|
+
async for raw_line in iter_bytes_lines(stream):
|
|
245
|
+
yield raw_line.rstrip(b"\n")
|
|
246
|
+
|
|
247
|
+
def decode_error_events(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
raw: str,
|
|
251
|
+
line: str,
|
|
252
|
+
error: Exception,
|
|
253
|
+
state: Any,
|
|
254
|
+
) -> list[TakopiEvent]:
|
|
255
|
+
message = f"invalid event from {self.tag()}; ignoring line"
|
|
256
|
+
detail = {"line": line, "error": str(error)}
|
|
257
|
+
return [self.note_event(message, state=state, detail=detail)]
|
|
258
|
+
|
|
259
|
+
def translate_error_events(
|
|
260
|
+
self,
|
|
261
|
+
*,
|
|
262
|
+
data: Any,
|
|
263
|
+
error: Exception,
|
|
264
|
+
state: Any,
|
|
265
|
+
) -> list[TakopiEvent]:
|
|
266
|
+
message = f"{self.tag()} translation error; ignoring event"
|
|
267
|
+
detail: dict[str, Any] = {"error": str(error)}
|
|
268
|
+
if isinstance(data, dict):
|
|
269
|
+
detail["type"] = data.get("type")
|
|
270
|
+
item = data.get("item")
|
|
271
|
+
if isinstance(item, dict):
|
|
272
|
+
detail["item_type"] = item.get("type") or item.get("item_type")
|
|
273
|
+
return [self.note_event(message, state=state, detail=detail)]
|
|
274
|
+
|
|
275
|
+
def process_error_events(
|
|
276
|
+
self,
|
|
277
|
+
rc: int,
|
|
278
|
+
*,
|
|
279
|
+
resume: ResumeToken | None,
|
|
280
|
+
found_session: ResumeToken | None,
|
|
281
|
+
state: Any,
|
|
282
|
+
) -> list[TakopiEvent]:
|
|
283
|
+
message = f"{self.tag()} failed (rc={rc})."
|
|
284
|
+
resume_for_completed = found_session or resume
|
|
285
|
+
return [
|
|
286
|
+
self.note_event(message, state=state),
|
|
287
|
+
CompletedEvent(
|
|
288
|
+
engine=self.engine,
|
|
289
|
+
ok=False,
|
|
290
|
+
answer="",
|
|
291
|
+
resume=resume_for_completed,
|
|
292
|
+
error=message,
|
|
293
|
+
),
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
def stream_end_events(
|
|
297
|
+
self,
|
|
298
|
+
*,
|
|
299
|
+
resume: ResumeToken | None,
|
|
300
|
+
found_session: ResumeToken | None,
|
|
301
|
+
state: Any,
|
|
302
|
+
) -> list[TakopiEvent]:
|
|
303
|
+
message = f"{self.tag()} finished without a result event"
|
|
304
|
+
resume_for_completed = found_session or resume
|
|
305
|
+
return [
|
|
306
|
+
CompletedEvent(
|
|
307
|
+
engine=self.engine,
|
|
308
|
+
ok=False,
|
|
309
|
+
answer="",
|
|
310
|
+
resume=resume_for_completed,
|
|
311
|
+
error=message,
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
def translate(
|
|
316
|
+
self,
|
|
317
|
+
data: Any,
|
|
318
|
+
*,
|
|
319
|
+
state: Any,
|
|
320
|
+
resume: ResumeToken | None,
|
|
321
|
+
found_session: ResumeToken | None,
|
|
322
|
+
) -> list[TakopiEvent]:
|
|
323
|
+
raise NotImplementedError
|
|
324
|
+
|
|
325
|
+
def handle_started_event(
|
|
326
|
+
self,
|
|
327
|
+
event: StartedEvent,
|
|
328
|
+
*,
|
|
329
|
+
expected_session: ResumeToken | None,
|
|
330
|
+
found_session: ResumeToken | None,
|
|
331
|
+
) -> tuple[ResumeToken | None, bool]:
|
|
332
|
+
if event.engine != self.engine:
|
|
333
|
+
raise RuntimeError(
|
|
334
|
+
f"{self.tag()} emitted session token for engine {event.engine!r}"
|
|
335
|
+
)
|
|
336
|
+
if expected_session is not None and event.resume != expected_session:
|
|
337
|
+
message = (
|
|
338
|
+
f"{self.tag()} emitted session id {event.resume.value} "
|
|
339
|
+
f"but expected {expected_session.value}"
|
|
340
|
+
)
|
|
341
|
+
raise RuntimeError(message)
|
|
342
|
+
if found_session is None:
|
|
343
|
+
return event.resume, True
|
|
344
|
+
if event.resume != found_session:
|
|
345
|
+
message = (
|
|
346
|
+
f"{self.tag()} emitted session id {event.resume.value} "
|
|
347
|
+
f"but expected {found_session.value}"
|
|
348
|
+
)
|
|
349
|
+
raise RuntimeError(message)
|
|
350
|
+
return found_session, False
|
|
351
|
+
|
|
352
|
+
async def _send_payload(
|
|
353
|
+
self,
|
|
354
|
+
proc: Any,
|
|
355
|
+
payload: bytes | None,
|
|
356
|
+
*,
|
|
357
|
+
logger: Any,
|
|
358
|
+
resume: ResumeToken | None,
|
|
359
|
+
) -> None:
|
|
360
|
+
if payload is not None:
|
|
361
|
+
assert proc.stdin is not None
|
|
362
|
+
await proc.stdin.send(payload)
|
|
363
|
+
await proc.stdin.aclose()
|
|
364
|
+
logger.info(
|
|
365
|
+
"subprocess.stdin.send",
|
|
366
|
+
pid=proc.pid,
|
|
367
|
+
resume=resume.value if resume else None,
|
|
368
|
+
bytes=len(payload),
|
|
369
|
+
)
|
|
370
|
+
elif proc.stdin is not None:
|
|
371
|
+
await proc.stdin.aclose()
|
|
372
|
+
|
|
373
|
+
def _decode_jsonl_events(
|
|
374
|
+
self,
|
|
375
|
+
*,
|
|
376
|
+
raw_line: bytes,
|
|
377
|
+
line: bytes,
|
|
378
|
+
jsonl_seq: int,
|
|
379
|
+
state: Any,
|
|
380
|
+
resume: ResumeToken | None,
|
|
381
|
+
found_session: ResumeToken | None,
|
|
382
|
+
logger: Any,
|
|
383
|
+
pid: int,
|
|
384
|
+
) -> list[TakopiEvent]:
|
|
385
|
+
raw_text = raw_line.decode("utf-8", errors="replace")
|
|
386
|
+
line_text = line.decode("utf-8", errors="replace")
|
|
387
|
+
try:
|
|
388
|
+
decoded = self.decode_jsonl(line=line)
|
|
389
|
+
except Exception as exc: # noqa: BLE001
|
|
390
|
+
log_pipeline(
|
|
391
|
+
logger,
|
|
392
|
+
"jsonl.parse.error",
|
|
393
|
+
pid=pid,
|
|
394
|
+
jsonl_seq=jsonl_seq,
|
|
395
|
+
line=line_text,
|
|
396
|
+
error=str(exc),
|
|
397
|
+
)
|
|
398
|
+
return self.decode_error_events(
|
|
399
|
+
raw=raw_text,
|
|
400
|
+
line=line_text,
|
|
401
|
+
error=exc,
|
|
402
|
+
state=state,
|
|
403
|
+
)
|
|
404
|
+
if decoded is None:
|
|
405
|
+
log_pipeline(
|
|
406
|
+
logger,
|
|
407
|
+
"jsonl.parse.invalid",
|
|
408
|
+
pid=pid,
|
|
409
|
+
jsonl_seq=jsonl_seq,
|
|
410
|
+
line=line_text,
|
|
411
|
+
)
|
|
412
|
+
logger.info(
|
|
413
|
+
"runner.jsonl.invalid",
|
|
414
|
+
pid=pid,
|
|
415
|
+
jsonl_seq=jsonl_seq,
|
|
416
|
+
line=line_text,
|
|
417
|
+
)
|
|
418
|
+
return self.invalid_json_events(
|
|
419
|
+
raw=raw_text,
|
|
420
|
+
line=line_text,
|
|
421
|
+
state=state,
|
|
422
|
+
)
|
|
423
|
+
try:
|
|
424
|
+
return self.translate(
|
|
425
|
+
decoded,
|
|
426
|
+
state=state,
|
|
427
|
+
resume=resume,
|
|
428
|
+
found_session=found_session,
|
|
429
|
+
)
|
|
430
|
+
except Exception as exc: # noqa: BLE001
|
|
431
|
+
log_pipeline(
|
|
432
|
+
logger,
|
|
433
|
+
"runner.translate.error",
|
|
434
|
+
pid=pid,
|
|
435
|
+
jsonl_seq=jsonl_seq,
|
|
436
|
+
error=str(exc),
|
|
437
|
+
)
|
|
438
|
+
return self.translate_error_events(
|
|
439
|
+
data=decoded,
|
|
440
|
+
error=exc,
|
|
441
|
+
state=state,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _process_started_event(
|
|
445
|
+
self,
|
|
446
|
+
event: StartedEvent,
|
|
447
|
+
*,
|
|
448
|
+
expected_session: ResumeToken | None,
|
|
449
|
+
found_session: ResumeToken | None,
|
|
450
|
+
logger: Any,
|
|
451
|
+
pid: int,
|
|
452
|
+
jsonl_seq: int,
|
|
453
|
+
) -> tuple[ResumeToken | None, bool]:
|
|
454
|
+
prior_found = found_session
|
|
455
|
+
try:
|
|
456
|
+
found_session, emit = self.handle_started_event(
|
|
457
|
+
event,
|
|
458
|
+
expected_session=expected_session,
|
|
459
|
+
found_session=found_session,
|
|
460
|
+
)
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
log_pipeline(
|
|
463
|
+
logger,
|
|
464
|
+
"runner.started.error",
|
|
465
|
+
pid=pid,
|
|
466
|
+
jsonl_seq=jsonl_seq,
|
|
467
|
+
resume=event.resume.value,
|
|
468
|
+
expected_session=expected_session.value if expected_session else None,
|
|
469
|
+
found_session=prior_found.value if prior_found else None,
|
|
470
|
+
error=str(exc),
|
|
471
|
+
)
|
|
472
|
+
raise
|
|
473
|
+
if prior_found is None and emit:
|
|
474
|
+
reason = (
|
|
475
|
+
"matched_expected" if expected_session is not None else "first_seen"
|
|
476
|
+
)
|
|
477
|
+
elif prior_found is not None and not emit:
|
|
478
|
+
reason = "duplicate"
|
|
479
|
+
else:
|
|
480
|
+
reason = "unknown"
|
|
481
|
+
log_pipeline(
|
|
482
|
+
logger,
|
|
483
|
+
"runner.started.seen",
|
|
484
|
+
pid=pid,
|
|
485
|
+
jsonl_seq=jsonl_seq,
|
|
486
|
+
resume=event.resume.value,
|
|
487
|
+
expected_session=expected_session.value if expected_session else None,
|
|
488
|
+
found_session=found_session.value if found_session else None,
|
|
489
|
+
emit=emit,
|
|
490
|
+
reason=reason,
|
|
491
|
+
)
|
|
492
|
+
return found_session, emit
|
|
493
|
+
|
|
494
|
+
def _log_completed_event(
|
|
495
|
+
self,
|
|
496
|
+
*,
|
|
497
|
+
logger: Any,
|
|
498
|
+
pid: int,
|
|
499
|
+
event: CompletedEvent,
|
|
500
|
+
jsonl_seq: int | None = None,
|
|
501
|
+
source: str | None = None,
|
|
502
|
+
) -> None:
|
|
503
|
+
payload: dict[str, Any] = {
|
|
504
|
+
"pid": pid,
|
|
505
|
+
"ok": event.ok,
|
|
506
|
+
"has_answer": bool(event.answer.strip()),
|
|
507
|
+
"emit": True,
|
|
508
|
+
}
|
|
509
|
+
if jsonl_seq is not None:
|
|
510
|
+
payload["jsonl_seq"] = jsonl_seq
|
|
511
|
+
if source is not None:
|
|
512
|
+
payload["source"] = source
|
|
513
|
+
log_pipeline(logger, "runner.completed.seen", **payload)
|
|
514
|
+
|
|
515
|
+
def _handle_jsonl_line(
|
|
516
|
+
self,
|
|
517
|
+
*,
|
|
518
|
+
raw_line: bytes,
|
|
519
|
+
stream: JsonlStreamState,
|
|
520
|
+
state: Any,
|
|
521
|
+
resume: ResumeToken | None,
|
|
522
|
+
logger: Any,
|
|
523
|
+
pid: int,
|
|
524
|
+
) -> list[TakopiEvent]:
|
|
525
|
+
if stream.did_emit_completed:
|
|
526
|
+
if not stream.ignored_after_completed:
|
|
527
|
+
log_pipeline(
|
|
528
|
+
logger,
|
|
529
|
+
"runner.drop.jsonl_after_completed",
|
|
530
|
+
pid=pid,
|
|
531
|
+
)
|
|
532
|
+
stream.ignored_after_completed = True
|
|
533
|
+
return []
|
|
534
|
+
line = raw_line.strip()
|
|
535
|
+
if not line:
|
|
536
|
+
return []
|
|
537
|
+
stream.jsonl_seq += 1
|
|
538
|
+
seq = stream.jsonl_seq
|
|
539
|
+
events = self._decode_jsonl_events(
|
|
540
|
+
raw_line=raw_line,
|
|
541
|
+
line=line,
|
|
542
|
+
jsonl_seq=seq,
|
|
543
|
+
state=state,
|
|
544
|
+
resume=resume,
|
|
545
|
+
found_session=stream.found_session,
|
|
546
|
+
logger=logger,
|
|
547
|
+
pid=pid,
|
|
548
|
+
)
|
|
549
|
+
output: list[TakopiEvent] = []
|
|
550
|
+
for evt in events:
|
|
551
|
+
if isinstance(evt, StartedEvent):
|
|
552
|
+
stream.found_session, emit = self._process_started_event(
|
|
553
|
+
evt,
|
|
554
|
+
expected_session=stream.expected_session,
|
|
555
|
+
found_session=stream.found_session,
|
|
556
|
+
logger=logger,
|
|
557
|
+
pid=pid,
|
|
558
|
+
jsonl_seq=seq,
|
|
559
|
+
)
|
|
560
|
+
if not emit:
|
|
561
|
+
continue
|
|
562
|
+
if isinstance(evt, CompletedEvent):
|
|
563
|
+
stream.did_emit_completed = True
|
|
564
|
+
self._log_completed_event(
|
|
565
|
+
logger=logger,
|
|
566
|
+
pid=pid,
|
|
567
|
+
event=evt,
|
|
568
|
+
jsonl_seq=seq,
|
|
569
|
+
)
|
|
570
|
+
output.append(evt)
|
|
571
|
+
break
|
|
572
|
+
output.append(evt)
|
|
573
|
+
return output
|
|
574
|
+
|
|
575
|
+
async def _iter_jsonl_events(
|
|
576
|
+
self,
|
|
577
|
+
*,
|
|
578
|
+
stdout: Any,
|
|
579
|
+
stream: JsonlStreamState,
|
|
580
|
+
state: Any,
|
|
581
|
+
resume: ResumeToken | None,
|
|
582
|
+
logger: Any,
|
|
583
|
+
pid: int,
|
|
584
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
585
|
+
async for raw_line in self.iter_json_lines(stdout):
|
|
586
|
+
for evt in self._handle_jsonl_line(
|
|
587
|
+
raw_line=raw_line,
|
|
588
|
+
stream=stream,
|
|
589
|
+
state=state,
|
|
590
|
+
resume=resume,
|
|
591
|
+
logger=logger,
|
|
592
|
+
pid=pid,
|
|
593
|
+
):
|
|
594
|
+
yield evt
|
|
595
|
+
|
|
596
|
+
async def run_impl(
|
|
597
|
+
self, prompt: str, resume: ResumeToken | None
|
|
598
|
+
) -> AsyncIterator[TakopiEvent]:
|
|
599
|
+
state = self.new_state(prompt, resume)
|
|
600
|
+
self.start_run(prompt, resume, state=state)
|
|
601
|
+
|
|
602
|
+
tag = self.tag()
|
|
603
|
+
logger = self.get_logger()
|
|
604
|
+
cmd = [self.command(), *self.build_args(prompt, resume, state=state)]
|
|
605
|
+
payload = self.stdin_payload(prompt, resume, state=state)
|
|
606
|
+
env = self.env(state=state)
|
|
607
|
+
logger.info(
|
|
608
|
+
"runner.start",
|
|
609
|
+
engine=self.engine,
|
|
610
|
+
resume=resume.value if resume else None,
|
|
611
|
+
prompt=prompt,
|
|
612
|
+
prompt_len=len(prompt),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
cwd = get_run_base_dir()
|
|
616
|
+
|
|
617
|
+
async with manage_subprocess(
|
|
618
|
+
cmd,
|
|
619
|
+
stdin=subprocess.PIPE,
|
|
620
|
+
stdout=subprocess.PIPE,
|
|
621
|
+
stderr=subprocess.PIPE,
|
|
622
|
+
env=env,
|
|
623
|
+
cwd=cwd,
|
|
624
|
+
) as proc:
|
|
625
|
+
if proc.stdout is None or proc.stderr is None:
|
|
626
|
+
raise RuntimeError(self.pipes_error_message())
|
|
627
|
+
if payload is not None and proc.stdin is None:
|
|
628
|
+
raise RuntimeError(self.pipes_error_message())
|
|
629
|
+
|
|
630
|
+
logger.info(
|
|
631
|
+
"subprocess.spawn",
|
|
632
|
+
cmd=cmd[0] if cmd else None,
|
|
633
|
+
args=cmd[1:],
|
|
634
|
+
pid=proc.pid,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
await self._send_payload(proc, payload, logger=logger, resume=resume)
|
|
638
|
+
|
|
639
|
+
rc: int | None = None
|
|
640
|
+
stream = JsonlStreamState(expected_session=resume)
|
|
641
|
+
|
|
642
|
+
async with anyio.create_task_group() as tg:
|
|
643
|
+
tg.start_soon(
|
|
644
|
+
drain_stderr,
|
|
645
|
+
proc.stderr,
|
|
646
|
+
logger,
|
|
647
|
+
tag,
|
|
648
|
+
)
|
|
649
|
+
async for evt in self._iter_jsonl_events(
|
|
650
|
+
stdout=proc.stdout,
|
|
651
|
+
stream=stream,
|
|
652
|
+
state=state,
|
|
653
|
+
resume=resume,
|
|
654
|
+
logger=logger,
|
|
655
|
+
pid=proc.pid,
|
|
656
|
+
):
|
|
657
|
+
yield evt
|
|
658
|
+
|
|
659
|
+
rc = await proc.wait()
|
|
660
|
+
|
|
661
|
+
logger.info("subprocess.exit", pid=proc.pid, rc=rc)
|
|
662
|
+
if stream.did_emit_completed:
|
|
663
|
+
return
|
|
664
|
+
found_session = stream.found_session
|
|
665
|
+
if rc is not None and rc != 0:
|
|
666
|
+
events = self.process_error_events(
|
|
667
|
+
rc,
|
|
668
|
+
resume=resume,
|
|
669
|
+
found_session=found_session,
|
|
670
|
+
state=state,
|
|
671
|
+
)
|
|
672
|
+
for evt in events:
|
|
673
|
+
if isinstance(evt, CompletedEvent):
|
|
674
|
+
self._log_completed_event(
|
|
675
|
+
logger=logger,
|
|
676
|
+
pid=proc.pid,
|
|
677
|
+
event=evt,
|
|
678
|
+
source="process_error",
|
|
679
|
+
)
|
|
680
|
+
yield evt
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
events = self.stream_end_events(
|
|
684
|
+
resume=resume,
|
|
685
|
+
found_session=found_session,
|
|
686
|
+
state=state,
|
|
687
|
+
)
|
|
688
|
+
for evt in events:
|
|
689
|
+
if isinstance(evt, CompletedEvent):
|
|
690
|
+
self._log_completed_event(
|
|
691
|
+
logger=logger,
|
|
692
|
+
pid=proc.pid,
|
|
693
|
+
event=evt,
|
|
694
|
+
source="stream_end",
|
|
695
|
+
)
|
|
696
|
+
yield evt
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
class Runner(Protocol):
|
|
700
|
+
engine: str
|
|
701
|
+
|
|
702
|
+
def is_resume_line(self, line: str) -> bool: ...
|
|
703
|
+
|
|
704
|
+
def format_resume(self, token: ResumeToken) -> str: ...
|
|
705
|
+
|
|
706
|
+
def extract_resume(self, text: str | None) -> ResumeToken | None: ...
|
|
707
|
+
|
|
708
|
+
def run(
|
|
709
|
+
self,
|
|
710
|
+
prompt: str,
|
|
711
|
+
resume: ResumeToken | None,
|
|
712
|
+
) -> AsyncIterator[TakopiEvent]: ...
|