televibe 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.
- televibe/__init__.py +23 -0
- televibe/access.py +11 -0
- televibe/account.py +26 -0
- televibe/engine.py +177 -0
- televibe/env.py +69 -0
- televibe/errors.py +5 -0
- televibe/events.py +76 -0
- televibe/lines.py +44 -0
- televibe/markers.py +41 -0
- televibe/procinfo.py +50 -0
- televibe/providers/__init__.py +6 -0
- televibe/providers/base.py +101 -0
- televibe/providers/claude.py +101 -0
- televibe/providers/codex.py +97 -0
- televibe/py.typed +0 -0
- televibe/scheduler.py +76 -0
- televibe/session.py +57 -0
- televibe/telegram/__init__.py +13 -0
- televibe/telegram/chains.py +177 -0
- televibe/telegram/presenter.py +198 -0
- televibe/telegram/render.py +66 -0
- televibe/telegram/store.py +39 -0
- televibe/turn.py +332 -0
- televibe-0.1.0.dist-info/METADATA +48 -0
- televibe-0.1.0.dist-info/RECORD +27 -0
- televibe-0.1.0.dist-info/WHEEL +4 -0
- televibe-0.1.0.dist-info/licenses/LICENSE +21 -0
televibe/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Run headless coding agents one turn at a time, as resumable sessions with a typed event stream."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
if sys.platform == "win32":
|
|
6
|
+
raise ImportError(
|
|
7
|
+
"televibe supports Linux and macOS only: it kills an agent by its process group, "
|
|
8
|
+
"which Windows does not have"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from televibe.access import Access
|
|
12
|
+
from televibe.account import Account
|
|
13
|
+
from televibe.engine import Engine, Stranded
|
|
14
|
+
from televibe.errors import TelevibeError
|
|
15
|
+
from televibe.events import Done, Failed, FailReason, Message, Queued, Started, ToolUse, Warning
|
|
16
|
+
from televibe.providers import ClaudeCode, Codex
|
|
17
|
+
from televibe.session import Session
|
|
18
|
+
from televibe.turn import Turn
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Access", "Account", "ClaudeCode", "Codex", "Done", "Engine", "FailReason", "Failed", "Message",
|
|
22
|
+
"Queued", "Session", "Started", "Stranded", "TelevibeError", "ToolUse", "Turn", "Warning",
|
|
23
|
+
]
|
televibe/access.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""How much an agent may change (section 9)."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Access(enum.Enum):
|
|
7
|
+
"""The same name does not give the same guarantee across providers (see SPEC.md section 9)."""
|
|
8
|
+
|
|
9
|
+
READ_ONLY = "read_only"
|
|
10
|
+
WORKSPACE_WRITE = "workspace_write"
|
|
11
|
+
FULL = "full"
|
televibe/account.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""An agent account: one directory with its login, settings and transcripts (section 3)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
|
|
8
|
+
from televibe.env import check_credentials
|
|
9
|
+
from televibe.errors import TelevibeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Account:
|
|
13
|
+
__slots__ = ("_credentials", "home")
|
|
14
|
+
|
|
15
|
+
def __init__(self, home: str | os.PathLike[str], credentials: Mapping[str, str] | None = None) -> None:
|
|
16
|
+
if home is None:
|
|
17
|
+
raise TelevibeError("Account needs a home directory; televibe never falls back to ~/.claude or ~/.codex")
|
|
18
|
+
self.home = Path(home).expanduser().resolve()
|
|
19
|
+
self._credentials = check_credentials(credentials or {})
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def credentials(self) -> Mapping[str, str]:
|
|
23
|
+
return MappingProxyType(self._credentials)
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return f"Account(home={str(self.home)!r}, credentials=<{len(self._credentials)} hidden>)"
|
televibe/engine.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The Engine: ordering, recovery, and the factory for turns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import MappingProxyType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from televibe.access import Access
|
|
20
|
+
from televibe.env import check_turn_env
|
|
21
|
+
from televibe.errors import TelevibeError
|
|
22
|
+
from televibe.markers import Markers
|
|
23
|
+
from televibe.procinfo import kill_group, start_time
|
|
24
|
+
from televibe.providers.base import TurnOptions
|
|
25
|
+
from televibe.scheduler import Scheduler
|
|
26
|
+
from televibe.session import Session, unbound
|
|
27
|
+
from televibe.turn import Runner, Turn, TurnSpec
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger("televibe")
|
|
30
|
+
|
|
31
|
+
_EMPTY: Mapping[str, Any] = MappingProxyType({})
|
|
32
|
+
_TURN_ID = re.compile(r"[0-9a-f]{32}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Stranded:
|
|
37
|
+
"""A turn an earlier process left in flight (REQ-STATE-3).
|
|
38
|
+
|
|
39
|
+
`session` is not bound to a provider; run it again with
|
|
40
|
+
`provider.load_session(stranded.session.dump())`.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
turn_id: str
|
|
44
|
+
tag: dict[str, Any]
|
|
45
|
+
session: Session
|
|
46
|
+
started_at: datetime
|
|
47
|
+
killed: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _kill_if_same(pgid: object, recorded: object) -> bool:
|
|
51
|
+
"""Kill the group only if its leader exists and started when recorded (REQ-STATE-4)."""
|
|
52
|
+
if not isinstance(pgid, int) or not isinstance(recorded, str):
|
|
53
|
+
return False
|
|
54
|
+
if start_time(pgid) != recorded:
|
|
55
|
+
return False
|
|
56
|
+
kill_group(pgid)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Engine:
|
|
61
|
+
def __init__(self, state_dir: str | os.PathLike[str], max_concurrent: int = 1) -> None:
|
|
62
|
+
if isinstance(max_concurrent, bool) or not isinstance(max_concurrent, int) or max_concurrent < 1:
|
|
63
|
+
raise TelevibeError("max_concurrent must be a positive int")
|
|
64
|
+
self._markers = Markers(Path(state_dir).expanduser())
|
|
65
|
+
self._scheduler = Scheduler(max_concurrent)
|
|
66
|
+
self._runners: dict[str, Runner] = {}
|
|
67
|
+
self._state = "new"
|
|
68
|
+
self._lock_fd: int | None = None
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self) -> Engine:
|
|
71
|
+
if self._state != "new":
|
|
72
|
+
raise TelevibeError("an Engine is entered once")
|
|
73
|
+
self._markers.open()
|
|
74
|
+
self._lock()
|
|
75
|
+
self._state = "open"
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def _lock(self) -> None:
|
|
79
|
+
"""One engine per state_dir, so stranded() never sees another engine's live turn (REQ-STATE-3)."""
|
|
80
|
+
state_dir = self._markers.dir.parent
|
|
81
|
+
fd = os.open(state_dir / "lock", os.O_RDWR | os.O_CREAT, 0o600)
|
|
82
|
+
try:
|
|
83
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
84
|
+
except BlockingIOError:
|
|
85
|
+
os.close(fd)
|
|
86
|
+
raise TelevibeError(f"state_dir {state_dir} is in use by another Engine") from None
|
|
87
|
+
self._lock_fd = fd
|
|
88
|
+
|
|
89
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
90
|
+
self._state = "closed"
|
|
91
|
+
runners = list(self._runners.values())
|
|
92
|
+
for runner in runners:
|
|
93
|
+
runner.cancel("engine closed") # REQ-CANCEL-7
|
|
94
|
+
await asyncio.gather(*(runner.wait_ended() for runner in runners), return_exceptions=True)
|
|
95
|
+
if self._lock_fd is not None:
|
|
96
|
+
os.close(self._lock_fd) # closing the descriptor releases the lock
|
|
97
|
+
self._lock_fd = None
|
|
98
|
+
|
|
99
|
+
def _require_open(self) -> None:
|
|
100
|
+
if self._state != "open":
|
|
101
|
+
raise TelevibeError("the Engine is not open: use it inside `async with Engine(...)`")
|
|
102
|
+
|
|
103
|
+
def turn(
|
|
104
|
+
self,
|
|
105
|
+
prompt: str,
|
|
106
|
+
*,
|
|
107
|
+
session: Session,
|
|
108
|
+
access: Access = Access.READ_ONLY,
|
|
109
|
+
lane: str | None = None,
|
|
110
|
+
model: str | None = None,
|
|
111
|
+
instructions: str | None = None,
|
|
112
|
+
env: Mapping[str, str] = _EMPTY,
|
|
113
|
+
add_dirs: Sequence[str | os.PathLike[str]] = (),
|
|
114
|
+
timeout_s: float = 900,
|
|
115
|
+
tag: Mapping[str, Any] = _EMPTY,
|
|
116
|
+
) -> Turn:
|
|
117
|
+
self._require_open()
|
|
118
|
+
if not isinstance(prompt, str) or not prompt:
|
|
119
|
+
raise TelevibeError("prompt must be a non-empty str")
|
|
120
|
+
try:
|
|
121
|
+
prompt.encode()
|
|
122
|
+
except UnicodeEncodeError:
|
|
123
|
+
raise TelevibeError("prompt must be text that encodes to UTF-8 (it holds a lone surrogate)") from None
|
|
124
|
+
if not isinstance(session, Session):
|
|
125
|
+
raise TelevibeError(f"session must be a Session, got {type(session).__name__}")
|
|
126
|
+
provider = session._bound
|
|
127
|
+
if provider is None:
|
|
128
|
+
raise TelevibeError("this session is not bound to a provider; use provider.load_session(session.dump())")
|
|
129
|
+
if not isinstance(access, Access):
|
|
130
|
+
raise TelevibeError(f"access must be an Access, got {access!r}")
|
|
131
|
+
provider.check_access(access)
|
|
132
|
+
for name, value in (("lane", lane), ("model", model), ("instructions", instructions)):
|
|
133
|
+
if value is not None and not isinstance(value, str):
|
|
134
|
+
raise TelevibeError(f"{name} must be a str or None")
|
|
135
|
+
if value is not None and "\0" in value:
|
|
136
|
+
raise TelevibeError(f"{name} must not contain a NUL character")
|
|
137
|
+
if isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) or not math.isfinite(timeout_s) or timeout_s <= 0:
|
|
138
|
+
raise TelevibeError("timeout_s must be a positive, finite number of seconds")
|
|
139
|
+
if isinstance(add_dirs, (str, bytes, os.PathLike)):
|
|
140
|
+
raise TelevibeError("add_dirs must be a sequence of paths, not one path")
|
|
141
|
+
if any("\0" in os.fspath(d) for d in add_dirs if isinstance(d, (str, os.PathLike))):
|
|
142
|
+
raise TelevibeError("add_dirs must not contain a NUL character")
|
|
143
|
+
dirs = tuple(Path(d).expanduser().resolve() for d in add_dirs)
|
|
144
|
+
turn_env = check_turn_env(env)
|
|
145
|
+
try:
|
|
146
|
+
tag_copy = json.loads(json.dumps(dict(tag)))
|
|
147
|
+
except (TypeError, ValueError) as exc:
|
|
148
|
+
raise TelevibeError(f"tag must be a JSON-serializable mapping: {exc}") from None
|
|
149
|
+
spec = TurnSpec(prompt, TurnOptions(access, model, instructions, dirs), lane, turn_env, float(timeout_s), tag_copy)
|
|
150
|
+
return Turn(Runner(self, provider, provider.prepare(session), spec))
|
|
151
|
+
|
|
152
|
+
async def stranded(self) -> list[Stranded]:
|
|
153
|
+
"""Every marker an earlier process left; kills its agent if still alive (REQ-STATE-3, REQ-STATE-4)."""
|
|
154
|
+
self._require_open()
|
|
155
|
+
found = []
|
|
156
|
+
for marker in self._markers.all():
|
|
157
|
+
turn_id = marker.get("turn_id")
|
|
158
|
+
if not isinstance(turn_id, str) or turn_id in self._runners:
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
session = unbound(marker["session"])
|
|
162
|
+
started_at = datetime.fromisoformat(marker["started_at"])
|
|
163
|
+
except (KeyError, TypeError, ValueError, TelevibeError):
|
|
164
|
+
log.warning("televibe: marker %s is malformed; skipping it", turn_id)
|
|
165
|
+
continue
|
|
166
|
+
killed = await asyncio.to_thread(_kill_if_same, marker.get("pgid"), marker.get("proc_start"))
|
|
167
|
+
found.append(Stranded(turn_id, dict(marker.get("tag") or {}), session, started_at, killed))
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
def forget(self, turn_id: str) -> None:
|
|
171
|
+
"""Remove a stranded marker (REQ-STATE-5)."""
|
|
172
|
+
self._require_open()
|
|
173
|
+
if not isinstance(turn_id, str) or not _TURN_ID.fullmatch(turn_id):
|
|
174
|
+
raise TelevibeError(f"not a turn id: {turn_id!r}")
|
|
175
|
+
if turn_id in self._runners:
|
|
176
|
+
raise TelevibeError(f"turn {turn_id} is live in this engine, not stranded")
|
|
177
|
+
self._markers.remove(turn_id)
|
televibe/env.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The child process environment (REQ-ENV-1..3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from televibe.errors import TelevibeError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from televibe.account import Account
|
|
13
|
+
|
|
14
|
+
INHERITED = frozenset({"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM", "TMPDIR", "TZ"})
|
|
15
|
+
API_KEYS = frozenset({"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY"})
|
|
16
|
+
ACCOUNT_VARS = frozenset({"CLAUDE_CONFIG_DIR", "CODEX_HOME"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_str_map(env: Mapping[str, str], where: str) -> dict[str, str]:
|
|
20
|
+
if not isinstance(env, Mapping):
|
|
21
|
+
raise TelevibeError(f"{where} must be a mapping of str to str")
|
|
22
|
+
for key, value in env.items():
|
|
23
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
24
|
+
raise TelevibeError(f"{where} must map str to str; {key!r} does not")
|
|
25
|
+
if not key or "=" in key or "\0" in key or "\0" in value:
|
|
26
|
+
raise TelevibeError(f"{where}: {key!r} is not a name and value a process environment can hold")
|
|
27
|
+
return dict(env)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def refuse_account_vars(names: Iterable[str], where: str) -> None:
|
|
31
|
+
found = sorted(ACCOUNT_VARS.intersection(names))
|
|
32
|
+
if found:
|
|
33
|
+
raise TelevibeError(f"{', '.join(found)} refused in {where}: choose the account through Account")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def refuse_api_keys(names: Iterable[str], where: str) -> None:
|
|
37
|
+
found = sorted(API_KEYS.intersection(names))
|
|
38
|
+
if found:
|
|
39
|
+
raise TelevibeError(
|
|
40
|
+
f"{', '.join(found)} refused in {where}: televibe v1 runs agents on subscriptions only, "
|
|
41
|
+
"and an API key would bill the API"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_credentials(credentials: Mapping[str, str]) -> dict[str, str]:
|
|
46
|
+
checked = _check_str_map(credentials, "credentials")
|
|
47
|
+
refuse_api_keys(checked, "credentials")
|
|
48
|
+
refuse_account_vars(checked, "credentials")
|
|
49
|
+
return checked
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_turn_env(env: Mapping[str, str]) -> dict[str, str]:
|
|
53
|
+
checked = _check_str_map(env, "a turn's env")
|
|
54
|
+
refuse_api_keys(checked, "a turn's env")
|
|
55
|
+
refuse_account_vars(checked, "a turn's env")
|
|
56
|
+
return checked
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def child_env(
|
|
60
|
+
account_var: str,
|
|
61
|
+
account: Account,
|
|
62
|
+
turn_env: Mapping[str, str],
|
|
63
|
+
parent: Mapping[str, str] = os.environ,
|
|
64
|
+
) -> dict[str, str]:
|
|
65
|
+
env = {name: value for name, value in parent.items() if name in INHERITED or name.startswith("LC_")}
|
|
66
|
+
env[account_var] = str(account.home)
|
|
67
|
+
env.update(account.credentials)
|
|
68
|
+
env.update(turn_env)
|
|
69
|
+
return env
|
televibe/errors.py
ADDED
televibe/events.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The events a turn yields (section 5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from televibe.session import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FailReason(enum.StrEnum):
|
|
14
|
+
TIMEOUT = "timeout"
|
|
15
|
+
CANCELLED = "cancelled"
|
|
16
|
+
SESSION_LOST = "session_lost"
|
|
17
|
+
PROVIDER_ERROR = "provider_error"
|
|
18
|
+
SPAWN_ERROR = "spawn_error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Queued:
|
|
23
|
+
"""The turn is waiting for a free slot, lane or session."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Started:
|
|
28
|
+
"""The agent process is running and reported its session id."""
|
|
29
|
+
|
|
30
|
+
session: Session
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Message:
|
|
35
|
+
"""One complete assistant message."""
|
|
36
|
+
|
|
37
|
+
text: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ToolUse:
|
|
42
|
+
"""The agent started a tool (a command, an edit, a search)."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
detail: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Warning: # noqa: A001 - the spec's name; it shadows the builtin only inside this module
|
|
50
|
+
"""The agent reported a problem that did not end the turn."""
|
|
51
|
+
|
|
52
|
+
text: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Done:
|
|
57
|
+
"""Terminal: the turn finished. `text` is the final answer."""
|
|
58
|
+
|
|
59
|
+
session: Session
|
|
60
|
+
text: str
|
|
61
|
+
usage: dict[str, Any]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Failed:
|
|
66
|
+
"""Terminal: the turn did not finish."""
|
|
67
|
+
|
|
68
|
+
reason: FailReason
|
|
69
|
+
session: Session | None
|
|
70
|
+
detail: str
|
|
71
|
+
partial: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
type Event = Queued | Started | Message | ToolUse | Warning | Done | Failed
|
|
75
|
+
|
|
76
|
+
TERMINAL = (Done, Failed)
|
televibe/lines.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""JSON records from an agent's stdout, one per line (REQ-RUN-3)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
LINE_LIMIT = 8 * 1024 * 1024
|
|
9
|
+
_CHUNK = 64 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _decode(line: bytes) -> dict[str, Any] | None:
|
|
13
|
+
line = line.strip()
|
|
14
|
+
if not line:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
value = json.loads(line)
|
|
18
|
+
except ValueError: # includes UnicodeDecodeError
|
|
19
|
+
return None
|
|
20
|
+
return value if isinstance(value, dict) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def json_records(stream: asyncio.StreamReader, limit: int = LINE_LIMIT) -> AsyncIterator[dict[str, Any]]:
|
|
24
|
+
"""Yield each line that is a JSON object; skip everything else, including lines over `limit` bytes."""
|
|
25
|
+
buf = bytearray()
|
|
26
|
+
skipping = False # inside a line that already went over the limit
|
|
27
|
+
while chunk := await stream.read(_CHUNK):
|
|
28
|
+
start = 0
|
|
29
|
+
while (newline := chunk.find(b"\n", start)) >= 0:
|
|
30
|
+
if skipping:
|
|
31
|
+
skipping = False
|
|
32
|
+
else:
|
|
33
|
+
buf += chunk[start:newline]
|
|
34
|
+
if len(buf) <= limit and (record := _decode(bytes(buf))) is not None:
|
|
35
|
+
yield record
|
|
36
|
+
buf.clear()
|
|
37
|
+
start = newline + 1
|
|
38
|
+
if not skipping:
|
|
39
|
+
buf += chunk[start:]
|
|
40
|
+
if len(buf) > limit:
|
|
41
|
+
buf.clear()
|
|
42
|
+
skipping = True
|
|
43
|
+
if buf and not skipping and (record := _decode(bytes(buf))) is not None:
|
|
44
|
+
yield record
|
televibe/markers.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""One marker file per in-flight turn (section 11)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("televibe")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Markers:
|
|
13
|
+
def __init__(self, state_dir: Path) -> None:
|
|
14
|
+
self.dir = state_dir / "turns"
|
|
15
|
+
|
|
16
|
+
def open(self) -> None:
|
|
17
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
def write(self, turn_id: str, data: dict[str, Any]) -> None:
|
|
20
|
+
path = self.dir / f"{turn_id}.json"
|
|
21
|
+
tmp = self.dir / f".{turn_id}.json.tmp"
|
|
22
|
+
with open(tmp, "w") as f:
|
|
23
|
+
json.dump(data, f)
|
|
24
|
+
f.flush()
|
|
25
|
+
os.fsync(f.fileno())
|
|
26
|
+
os.replace(tmp, path)
|
|
27
|
+
|
|
28
|
+
def remove(self, turn_id: str) -> None:
|
|
29
|
+
(self.dir / f"{turn_id}.json").unlink(missing_ok=True)
|
|
30
|
+
|
|
31
|
+
def all(self) -> list[dict[str, Any]]:
|
|
32
|
+
found = []
|
|
33
|
+
for path in sorted(self.dir.glob("*.json")):
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(path.read_text())
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
log.warning("televibe: skipping unreadable marker %s", path)
|
|
38
|
+
continue
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
found.append(data)
|
|
41
|
+
return found
|
televibe/procinfo.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Process facts televibe needs: a start time to recognize a pid, and a group kill."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def start_time(pid: int) -> str | None:
|
|
11
|
+
"""An opaque start time for `pid`, or None if there is no such process.
|
|
12
|
+
|
|
13
|
+
Comparing it with a recorded value tells a live agent from a recycled pid (REQ-STATE-4).
|
|
14
|
+
"""
|
|
15
|
+
if sys.platform.startswith("linux"):
|
|
16
|
+
try:
|
|
17
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
18
|
+
except OSError:
|
|
19
|
+
return None
|
|
20
|
+
return stat.rsplit(")", 1)[1].split()[19] # field 22, starttime
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
24
|
+
capture_output=True,
|
|
25
|
+
text=True,
|
|
26
|
+
env={"PATH": "/bin:/usr/bin", "LC_ALL": "C"},
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
except OSError:
|
|
30
|
+
return None
|
|
31
|
+
value = result.stdout.strip()
|
|
32
|
+
return value if result.returncode == 0 and value else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def kill_group(pgid: int) -> None:
|
|
36
|
+
"""SIGKILL the whole process group; silent if it is already gone (REQ-CANCEL-1)."""
|
|
37
|
+
try:
|
|
38
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
39
|
+
except (ProcessLookupError, PermissionError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def group_alive(pgid: int) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
os.killpg(pgid, 0)
|
|
46
|
+
except ProcessLookupError:
|
|
47
|
+
return False
|
|
48
|
+
except PermissionError:
|
|
49
|
+
return True
|
|
50
|
+
return True
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""What every provider supplies: sessions, a command line, and a stream parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, ClassVar
|
|
10
|
+
|
|
11
|
+
from televibe.access import Access
|
|
12
|
+
from televibe.account import Account
|
|
13
|
+
from televibe.errors import TelevibeError
|
|
14
|
+
from televibe.events import Done, Event, FailReason
|
|
15
|
+
from televibe.session import Session, parse_dump
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class TurnOptions:
|
|
20
|
+
access: Access
|
|
21
|
+
model: str | None
|
|
22
|
+
instructions: str | None
|
|
23
|
+
add_dirs: tuple[Path, ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Failure:
|
|
28
|
+
"""How a provider says a turn failed; the runner turns it into `Failed`."""
|
|
29
|
+
|
|
30
|
+
reason: FailReason
|
|
31
|
+
detail: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Parser(ABC):
|
|
35
|
+
"""Reads one turn's records. `session` gains its id once the agent reports it."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, session: Session) -> None:
|
|
38
|
+
self.session = session
|
|
39
|
+
self.terminal: Done | Failure | None = None
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def feed(self, record: dict[str, Any]) -> list[Event]:
|
|
43
|
+
"""Non-terminal events for one record; sets `terminal` on a terminal record."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def finish(self, returncode: int, stderr_tail: str) -> Done | Failure:
|
|
47
|
+
"""The turn's outcome once the process has exited."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Provider(ABC):
|
|
51
|
+
kind: ClassVar[str]
|
|
52
|
+
binary: ClassVar[str]
|
|
53
|
+
account_var: ClassVar[str]
|
|
54
|
+
access_levels: ClassVar[frozenset[Access]]
|
|
55
|
+
fixed_ids: ClassVar[bool]
|
|
56
|
+
|
|
57
|
+
def __init__(self, account: Account) -> None:
|
|
58
|
+
if not isinstance(account, Account):
|
|
59
|
+
raise TelevibeError(f"{type(self).__name__} needs an Account, got {type(account).__name__}")
|
|
60
|
+
self.account = account
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"{type(self).__name__}(account={self.account!r})"
|
|
64
|
+
|
|
65
|
+
def new_session(self, cwd: str | os.PathLike[str], id: str | None = None) -> Session:
|
|
66
|
+
if id is not None and not self.fixed_ids:
|
|
67
|
+
raise TelevibeError(f"{self.kind} names its own sessions; new_session takes no id")
|
|
68
|
+
if id is not None and (not isinstance(id, str) or not id):
|
|
69
|
+
raise TelevibeError("a fixed session id must be a non-empty str")
|
|
70
|
+
return self._session(cwd, id, started=False)
|
|
71
|
+
|
|
72
|
+
def resume_session(self, cwd: str | os.PathLike[str], id: str) -> Session:
|
|
73
|
+
if not isinstance(id, str) or not id:
|
|
74
|
+
raise TelevibeError("resume_session needs the session's non-empty id")
|
|
75
|
+
return self._session(cwd, id, started=True)
|
|
76
|
+
|
|
77
|
+
def load_session(self, text: str) -> Session:
|
|
78
|
+
data = parse_dump(text)
|
|
79
|
+
if data["provider"] != self.kind:
|
|
80
|
+
raise TelevibeError(f"this session belongs to {data['provider']!r}, not {self.kind!r}")
|
|
81
|
+
if Path(data["home"]) != self.account.home:
|
|
82
|
+
raise TelevibeError(f"this session belongs to account home {data['home']}, not {self.account.home}")
|
|
83
|
+
return Session(self.kind, self.account.home, Path(data["cwd"]), data["id"], data["started"], _bound=self)
|
|
84
|
+
|
|
85
|
+
def _session(self, cwd: str | os.PathLike[str], id: str | None, *, started: bool) -> Session:
|
|
86
|
+
return Session(self.kind, self.account.home, Path(cwd).expanduser().resolve(), id, started, _bound=self)
|
|
87
|
+
|
|
88
|
+
def check_access(self, access: Access) -> None:
|
|
89
|
+
if access not in self.access_levels:
|
|
90
|
+
supported = ", ".join(sorted(level.name for level in self.access_levels))
|
|
91
|
+
raise TelevibeError(f"{self.kind} does not support Access.{access.name}; it supports {supported}")
|
|
92
|
+
|
|
93
|
+
def prepare(self, session: Session) -> Session:
|
|
94
|
+
"""The session as the turn will run it."""
|
|
95
|
+
return session
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def argv(self, session: Session, options: TurnOptions) -> list[str]: ...
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
def parser(self, session: Session) -> Parser: ...
|