codex-telegram-bridge 0.5.3__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.
- agent_runtime/__init__.py +17 -0
- agent_runtime/adapters/__init__.py +6 -0
- agent_runtime/adapters/base.py +32 -0
- agent_runtime/adapters/codex_repl.py +109 -0
- agent_runtime/approvals.py +87 -0
- agent_runtime/capabilities.py +59 -0
- agent_runtime/locks.py +111 -0
- agent_runtime/transport.py +77 -0
- agent_runtime/types.py +33 -0
- bridge_setup.py +1134 -0
- bridge_watchdog.py +192 -0
- codex_audio_transcribe.py +66 -0
- codex_repl_bridge.py +5414 -0
- codex_telegram_bridge-0.5.3.dist-info/METADATA +620 -0
- codex_telegram_bridge-0.5.3.dist-info/RECORD +20 -0
- codex_telegram_bridge-0.5.3.dist-info/WHEEL +5 -0
- codex_telegram_bridge-0.5.3.dist-info/entry_points.txt +4 -0
- codex_telegram_bridge-0.5.3.dist-info/licenses/LICENSE +21 -0
- codex_telegram_bridge-0.5.3.dist-info/top_level.txt +6 -0
- telegram_agent_bridge.py +589 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared runtime contracts for Telegram-controlled Codex sessions."""
|
|
2
|
+
|
|
3
|
+
from .approvals import ApprovalOption, ApprovalRequest
|
|
4
|
+
from .capabilities import CapabilityRegistry, HeadCapabilities
|
|
5
|
+
from .locks import WorkdirLock, WorkdirLockError
|
|
6
|
+
from .types import AgentEvent, AgentMessage
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentEvent",
|
|
10
|
+
"AgentMessage",
|
|
11
|
+
"ApprovalOption",
|
|
12
|
+
"ApprovalRequest",
|
|
13
|
+
"CapabilityRegistry",
|
|
14
|
+
"HeadCapabilities",
|
|
15
|
+
"WorkdirLock",
|
|
16
|
+
"WorkdirLockError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Protocol implemented by Codex runtime heads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from agent_runtime.approvals import ApprovalOption
|
|
9
|
+
from agent_runtime.capabilities import HeadCapabilities
|
|
10
|
+
from agent_runtime.types import AgentEvent, AgentMessage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HeadAdapter(Protocol):
|
|
14
|
+
name: str
|
|
15
|
+
|
|
16
|
+
def spawn(self) -> None:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def send(self, message: AgentMessage) -> None:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def recv(self) -> Iterable[AgentEvent]:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def inject_approval(self, choice: ApprovalOption | str) -> None:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
def kill(self) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def capabilities(self) -> HeadCapabilities:
|
|
32
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Adapter for the visible Codex REPL controlled through tmux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agent_runtime.approvals import ApprovalOption
|
|
11
|
+
from agent_runtime.capabilities import HeadCapabilities
|
|
12
|
+
from agent_runtime.types import AgentEvent, AgentMessage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodexReplAdapter:
|
|
16
|
+
name = "codex_repl"
|
|
17
|
+
|
|
18
|
+
def __init__(self, repl: Any, workdir: Path | None = None) -> None:
|
|
19
|
+
self.repl = repl
|
|
20
|
+
self.workdir = workdir
|
|
21
|
+
self._session_path: Path | None = None
|
|
22
|
+
self._session_pos = 0
|
|
23
|
+
|
|
24
|
+
def spawn(self) -> None:
|
|
25
|
+
self.repl.verify()
|
|
26
|
+
|
|
27
|
+
def send(self, message: AgentMessage) -> None:
|
|
28
|
+
self.repl.paste_prompt(message.text)
|
|
29
|
+
|
|
30
|
+
def recv(self) -> Iterable[AgentEvent]:
|
|
31
|
+
path = self.session_file()
|
|
32
|
+
if path != self._session_path:
|
|
33
|
+
self._session_path = path
|
|
34
|
+
self._session_pos = 0
|
|
35
|
+
|
|
36
|
+
events: list[AgentEvent] = []
|
|
37
|
+
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
38
|
+
handle.seek(self._session_pos)
|
|
39
|
+
while True:
|
|
40
|
+
line = handle.readline()
|
|
41
|
+
if not line:
|
|
42
|
+
break
|
|
43
|
+
self._session_pos = handle.tell()
|
|
44
|
+
event = self._event_from_json_line(line)
|
|
45
|
+
if event:
|
|
46
|
+
events.append(event)
|
|
47
|
+
return events
|
|
48
|
+
|
|
49
|
+
def inject_approval(self, choice: ApprovalOption | str) -> None:
|
|
50
|
+
key = choice.key if isinstance(choice, ApprovalOption) else str(choice)
|
|
51
|
+
self.repl.send_approval_key(key)
|
|
52
|
+
|
|
53
|
+
def kill(self) -> None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def capabilities(self) -> HeadCapabilities:
|
|
57
|
+
roots = (self.workdir,) if self.workdir else ()
|
|
58
|
+
return HeadCapabilities(
|
|
59
|
+
head=self.name,
|
|
60
|
+
vision=True,
|
|
61
|
+
audio=True,
|
|
62
|
+
video=True,
|
|
63
|
+
repl=True,
|
|
64
|
+
approval=True,
|
|
65
|
+
streaming=True,
|
|
66
|
+
workdir_access=roots,
|
|
67
|
+
notes=("visible Codex TUI via tmux", "final answers read from Codex JSONL"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def capture_pane(self, lines: int = 80) -> str:
|
|
71
|
+
return self.repl.capture_pane(lines)
|
|
72
|
+
|
|
73
|
+
def clear_composer(self) -> None:
|
|
74
|
+
self.repl.clear_composer()
|
|
75
|
+
|
|
76
|
+
def session_file(self) -> Path:
|
|
77
|
+
return self.repl.session_file()
|
|
78
|
+
|
|
79
|
+
def _event_from_json_line(self, line: str) -> AgentEvent | None:
|
|
80
|
+
try:
|
|
81
|
+
record = json.loads(line)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return None
|
|
84
|
+
if not isinstance(record, dict):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
kind = record.get("type")
|
|
88
|
+
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
|
|
89
|
+
if kind == "event_msg":
|
|
90
|
+
payload_type = payload.get("type")
|
|
91
|
+
if payload_type == "user_message":
|
|
92
|
+
return AgentEvent("user", str(payload.get("message") or ""), source_head=self.name)
|
|
93
|
+
if payload_type == "agent_message" and payload.get("phase") == "final_answer":
|
|
94
|
+
return AgentEvent("assistant", str(payload.get("message") or ""), source_head=self.name)
|
|
95
|
+
|
|
96
|
+
if kind == "response_item":
|
|
97
|
+
if payload.get("type") == "message" and payload.get("role") == "assistant":
|
|
98
|
+
phase = payload.get("phase") or payload.get("metadata", {}).get("phase")
|
|
99
|
+
if phase != "final_answer":
|
|
100
|
+
return None
|
|
101
|
+
content = payload.get("content")
|
|
102
|
+
if isinstance(content, list):
|
|
103
|
+
parts = [
|
|
104
|
+
str(item.get("text") or "")
|
|
105
|
+
for item in content
|
|
106
|
+
if isinstance(item, dict) and item.get("type") == "output_text"
|
|
107
|
+
]
|
|
108
|
+
return AgentEvent("assistant", "\n".join(parts), source_head=self.name)
|
|
109
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Approval request model with expiry and cancellation state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ApprovalOption:
|
|
11
|
+
number: str
|
|
12
|
+
label: str
|
|
13
|
+
key: str
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def short_label(self) -> str:
|
|
17
|
+
label = self.label.strip()
|
|
18
|
+
lowered = label.lower()
|
|
19
|
+
if "don't ask" in lowered or "do not ask" in lowered:
|
|
20
|
+
return "Yes, don't ask again"
|
|
21
|
+
if lowered.startswith("no"):
|
|
22
|
+
return "No"
|
|
23
|
+
if lowered.startswith("yes"):
|
|
24
|
+
return "Yes"
|
|
25
|
+
return label[:40] or self.number
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ApprovalRequest:
|
|
30
|
+
approval_id: str
|
|
31
|
+
source_head: str
|
|
32
|
+
command: str
|
|
33
|
+
reason: str
|
|
34
|
+
options: tuple[ApprovalOption, ...]
|
|
35
|
+
expires_at: float
|
|
36
|
+
cancelled: bool = False
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def create(
|
|
40
|
+
cls,
|
|
41
|
+
approval_id: str,
|
|
42
|
+
source_head: str,
|
|
43
|
+
command: str,
|
|
44
|
+
reason: str,
|
|
45
|
+
options: tuple[ApprovalOption, ...],
|
|
46
|
+
ttl_seconds: int,
|
|
47
|
+
now: float | None = None,
|
|
48
|
+
) -> "ApprovalRequest":
|
|
49
|
+
base = time.time() if now is None else now
|
|
50
|
+
return cls(
|
|
51
|
+
approval_id=approval_id,
|
|
52
|
+
source_head=source_head,
|
|
53
|
+
command=command,
|
|
54
|
+
reason=reason,
|
|
55
|
+
options=options,
|
|
56
|
+
expires_at=base + max(1, ttl_seconds),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def signature(self) -> str:
|
|
61
|
+
return self.approval_id
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def short_signature(self) -> str:
|
|
65
|
+
return self.approval_id[:16]
|
|
66
|
+
|
|
67
|
+
def is_expired(self, now: float | None = None) -> bool:
|
|
68
|
+
current = time.time() if now is None else now
|
|
69
|
+
return current >= self.expires_at
|
|
70
|
+
|
|
71
|
+
def is_active(self, now: float | None = None) -> bool:
|
|
72
|
+
return not self.cancelled and not self.is_expired(now)
|
|
73
|
+
|
|
74
|
+
def cancel(self) -> "ApprovalRequest":
|
|
75
|
+
return replace(self, cancelled=True)
|
|
76
|
+
|
|
77
|
+
def option(self, choice: str) -> ApprovalOption | None:
|
|
78
|
+
return next((item for item in self.options if item.number == choice), None)
|
|
79
|
+
|
|
80
|
+
def telegram_text(self) -> str:
|
|
81
|
+
lines = [f"{self.source_head} is waiting for command approval.", ""]
|
|
82
|
+
if self.command:
|
|
83
|
+
lines.extend(["Command:", self.command[:600], ""])
|
|
84
|
+
if self.reason:
|
|
85
|
+
lines.extend(["Reason:", self.reason[:600], ""])
|
|
86
|
+
lines.append("Choose a button, or reply with the visible number/shortcut.")
|
|
87
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Capability declarations for Codex runtime heads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class HeadCapabilities:
|
|
11
|
+
head: str
|
|
12
|
+
vision: bool = False
|
|
13
|
+
audio: bool = False
|
|
14
|
+
video: bool = False
|
|
15
|
+
repl: bool = False
|
|
16
|
+
approval: bool = False
|
|
17
|
+
streaming: bool = False
|
|
18
|
+
workdir_access: tuple[Path, ...] = ()
|
|
19
|
+
notes: tuple[str, ...] = ()
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, object]:
|
|
22
|
+
return {
|
|
23
|
+
"head": self.head,
|
|
24
|
+
"vision": self.vision,
|
|
25
|
+
"audio": self.audio,
|
|
26
|
+
"video": self.video,
|
|
27
|
+
"repl": self.repl,
|
|
28
|
+
"approval": self.approval,
|
|
29
|
+
"streaming": self.streaming,
|
|
30
|
+
"workdir_access": [str(path) for path in self.workdir_access],
|
|
31
|
+
"notes": list(self.notes),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CapabilityRegistry:
|
|
37
|
+
_heads: dict[str, HeadCapabilities] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def register(self, capabilities: HeadCapabilities) -> None:
|
|
40
|
+
if not capabilities.head:
|
|
41
|
+
raise ValueError("head capability name is required")
|
|
42
|
+
self._heads[capabilities.head] = capabilities
|
|
43
|
+
|
|
44
|
+
def get(self, head: str) -> HeadCapabilities | None:
|
|
45
|
+
return self._heads.get(head)
|
|
46
|
+
|
|
47
|
+
def require(self, head: str) -> HeadCapabilities:
|
|
48
|
+
capabilities = self.get(head)
|
|
49
|
+
if capabilities is None:
|
|
50
|
+
raise KeyError(f"unknown head: {head}")
|
|
51
|
+
return capabilities
|
|
52
|
+
|
|
53
|
+
def supports(self, head: str, capability: str) -> bool:
|
|
54
|
+
capabilities = self.require(head)
|
|
55
|
+
value = getattr(capabilities, capability, None)
|
|
56
|
+
return bool(value)
|
|
57
|
+
|
|
58
|
+
def as_dict(self) -> dict[str, dict[str, object]]:
|
|
59
|
+
return {head: caps.as_dict() for head, caps in sorted(self._heads.items())}
|
agent_runtime/locks.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Workdir lock primitive for multi-head control planes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import hashlib
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkdirLockError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _process_alive(pid: int) -> bool:
|
|
18
|
+
if pid <= 0:
|
|
19
|
+
return False
|
|
20
|
+
try:
|
|
21
|
+
os.kill(pid, 0)
|
|
22
|
+
except ProcessLookupError:
|
|
23
|
+
return False
|
|
24
|
+
except PermissionError:
|
|
25
|
+
return True
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _lock_name(workdir: Path) -> str:
|
|
30
|
+
resolved = str(workdir.expanduser().resolve())
|
|
31
|
+
digest = hashlib.sha1(resolved.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
32
|
+
basename = workdir.name or "workdir"
|
|
33
|
+
safe_base = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "-" for ch in basename)
|
|
34
|
+
return f"{safe_base[:80]}-{digest}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class WorkdirLock:
|
|
39
|
+
workdir: Path
|
|
40
|
+
state_dir: Path
|
|
41
|
+
owner: str
|
|
42
|
+
stale_seconds: int = 24 * 60 * 60
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.workdir = self.workdir.expanduser().resolve()
|
|
46
|
+
self.state_dir = self.state_dir.expanduser().resolve()
|
|
47
|
+
self.lock_file = self.state_dir / "workdir-locks" / f"{_lock_name(self.workdir)}.lock"
|
|
48
|
+
self.acquired = False
|
|
49
|
+
|
|
50
|
+
def _payload(self) -> dict[str, object]:
|
|
51
|
+
return {
|
|
52
|
+
"owner": self.owner,
|
|
53
|
+
"pid": os.getpid(),
|
|
54
|
+
"workdir": str(self.workdir),
|
|
55
|
+
"created_at": time.time(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def _existing_payload(self) -> dict[str, object]:
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(self.lock_file.read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, json.JSONDecodeError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
def _remove_stale_lock_if_possible(self) -> bool:
|
|
65
|
+
payload = self._existing_payload()
|
|
66
|
+
pid = int(payload.get("pid") or 0)
|
|
67
|
+
created_at = float(payload.get("created_at") or 0)
|
|
68
|
+
if pid and _process_alive(pid):
|
|
69
|
+
return False
|
|
70
|
+
if created_at and time.time() - created_at < self.stale_seconds:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
self.lock_file.unlink()
|
|
74
|
+
return True
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def acquire(self) -> None:
|
|
79
|
+
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
while True:
|
|
81
|
+
try:
|
|
82
|
+
fd = os.open(str(self.lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
83
|
+
except FileExistsError as exc:
|
|
84
|
+
if self._remove_stale_lock_if_possible():
|
|
85
|
+
continue
|
|
86
|
+
payload = self._existing_payload()
|
|
87
|
+
raise WorkdirLockError(
|
|
88
|
+
f"workdir already locked by {payload.get('owner', 'unknown')}: {self.workdir}"
|
|
89
|
+
) from exc
|
|
90
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
91
|
+
json.dump(self._payload(), handle)
|
|
92
|
+
self.acquired = True
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
def release(self) -> None:
|
|
96
|
+
if not self.acquired:
|
|
97
|
+
return
|
|
98
|
+
payload = self._existing_payload()
|
|
99
|
+
if int(payload.get("pid") or 0) == os.getpid() and payload.get("owner") == self.owner:
|
|
100
|
+
try:
|
|
101
|
+
self.lock_file.unlink()
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
self.acquired = False
|
|
105
|
+
|
|
106
|
+
def __enter__(self) -> "WorkdirLock":
|
|
107
|
+
self.acquire()
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
111
|
+
self.release()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Transport abstractions for local control-plane input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import select
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Transport(Protocol):
|
|
14
|
+
def send(self, message: str) -> None:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
def recv(self, timeout: float | None = None) -> str:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QueueTransport:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._queue: queue.Queue[str] = queue.Queue()
|
|
27
|
+
|
|
28
|
+
def send(self, message: str) -> None:
|
|
29
|
+
self._queue.put(message)
|
|
30
|
+
|
|
31
|
+
def recv(self, timeout: float | None = None) -> str:
|
|
32
|
+
return self._queue.get(timeout=timeout)
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FifoTransport:
|
|
39
|
+
def __init__(self, path: Path) -> None:
|
|
40
|
+
self.path = path
|
|
41
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
if not self.path.exists():
|
|
43
|
+
os.mkfifo(self.path, 0o600)
|
|
44
|
+
|
|
45
|
+
def send(self, message: str) -> None:
|
|
46
|
+
with self.path.open("w", encoding="utf-8") as fifo:
|
|
47
|
+
fifo.write(message.rstrip("\n") + "\n")
|
|
48
|
+
|
|
49
|
+
def recv(self, timeout: float | None = None) -> str:
|
|
50
|
+
if timeout is None:
|
|
51
|
+
with self.path.open("r", encoding="utf-8") as fifo:
|
|
52
|
+
return fifo.readline().rstrip("\n")
|
|
53
|
+
|
|
54
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
55
|
+
fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
|
56
|
+
try:
|
|
57
|
+
chunks: list[bytes] = []
|
|
58
|
+
while True:
|
|
59
|
+
remaining = deadline - time.monotonic()
|
|
60
|
+
if remaining <= 0:
|
|
61
|
+
raise TimeoutError(f"timed out waiting for FIFO input: {self.path}")
|
|
62
|
+
readable, _, _ = select.select([fd], [], [], remaining)
|
|
63
|
+
if not readable:
|
|
64
|
+
raise TimeoutError(f"timed out waiting for FIFO input: {self.path}")
|
|
65
|
+
data = os.read(fd, 4096)
|
|
66
|
+
if not data:
|
|
67
|
+
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
68
|
+
continue
|
|
69
|
+
chunks.append(data)
|
|
70
|
+
if b"\n" in data:
|
|
71
|
+
break
|
|
72
|
+
return b"".join(chunks).split(b"\n", 1)[0].decode("utf-8", errors="replace")
|
|
73
|
+
finally:
|
|
74
|
+
os.close(fd)
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
return None
|
agent_runtime/types.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Common message and event types shared by head adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
AgentEventKind = Literal[
|
|
11
|
+
"started",
|
|
12
|
+
"user",
|
|
13
|
+
"assistant",
|
|
14
|
+
"approval_requested",
|
|
15
|
+
"approval_resolved",
|
|
16
|
+
"error",
|
|
17
|
+
"stopped",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class AgentMessage:
|
|
23
|
+
text: str
|
|
24
|
+
media_paths: tuple[Path, ...] = ()
|
|
25
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AgentEvent:
|
|
30
|
+
kind: AgentEventKind
|
|
31
|
+
text: str = ""
|
|
32
|
+
source_head: str = ""
|
|
33
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|