agent-tether 0.2.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.
- agent_tether/__init__.py +64 -0
- agent_tether/approval.py +142 -0
- agent_tether/batching.py +62 -0
- agent_tether/debounce.py +40 -0
- agent_tether/formatting.py +176 -0
- agent_tether/models.py +108 -0
- agent_tether/platforms/__init__.py +0 -0
- agent_tether/platforms/base.py +598 -0
- agent_tether/platforms/discord/__init__.py +0 -0
- agent_tether/platforms/discord/bridge.py +403 -0
- agent_tether/platforms/discord/pairing.py +90 -0
- agent_tether/platforms/slack/__init__.py +0 -0
- agent_tether/platforms/slack/bridge.py +287 -0
- agent_tether/platforms/telegram/__init__.py +0 -0
- agent_tether/platforms/telegram/bridge.py +619 -0
- agent_tether/platforms/telegram/formatting.py +197 -0
- agent_tether/py.typed +0 -0
- agent_tether/router.py +55 -0
- agent_tether/runner/__init__.py +14 -0
- agent_tether/runner/adapters/__init__.py +18 -0
- agent_tether/runner/protocol.py +192 -0
- agent_tether/runner/registry.py +81 -0
- agent_tether/state.py +105 -0
- agent_tether/subscriber.py +205 -0
- agent_tether-0.2.0.dist-info/METADATA +178 -0
- agent_tether-0.2.0.dist-info/RECORD +28 -0
- agent_tether-0.2.0.dist-info/WHEEL +4 -0
- agent_tether-0.2.0.dist-info/licenses/LICENSE +21 -0
agent_tether/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""agent-tether: Tether your AI agents to human oversight through chat platforms."""
|
|
2
|
+
|
|
3
|
+
from agent_tether.formatting import format_tool_input, humanize_key, humanize_enum_value
|
|
4
|
+
from agent_tether.models import ApprovalRequest, CommandDef, Handlers
|
|
5
|
+
from agent_tether.router import BridgeRouter
|
|
6
|
+
from agent_tether.subscriber import EventSubscriber
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
# Core bridge components
|
|
10
|
+
"ApprovalRequest",
|
|
11
|
+
"CommandDef",
|
|
12
|
+
"EventSubscriber",
|
|
13
|
+
"Handlers",
|
|
14
|
+
"BridgeRouter",
|
|
15
|
+
# Platform bridges (lazy loaded)
|
|
16
|
+
"TelegramBridge",
|
|
17
|
+
"SlackBridge",
|
|
18
|
+
"DiscordBridge",
|
|
19
|
+
# Formatting utilities
|
|
20
|
+
"format_tool_input",
|
|
21
|
+
"humanize_key",
|
|
22
|
+
"humanize_enum_value",
|
|
23
|
+
# Runner module (lazy loaded)
|
|
24
|
+
"runner",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name: str):
|
|
29
|
+
"""Lazy imports for platform bridges and session module — avoids requiring optional deps at import time."""
|
|
30
|
+
if name == "TelegramBridge":
|
|
31
|
+
try:
|
|
32
|
+
from agent_tether.platforms.telegram.bridge import TelegramBridge
|
|
33
|
+
|
|
34
|
+
return TelegramBridge
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"TelegramBridge requires python-telegram-bot. "
|
|
38
|
+
"Install with: pip install agent-tether[telegram]"
|
|
39
|
+
) from None
|
|
40
|
+
if name == "SlackBridge":
|
|
41
|
+
try:
|
|
42
|
+
from agent_tether.platforms.slack.bridge import SlackBridge
|
|
43
|
+
|
|
44
|
+
return SlackBridge
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"SlackBridge requires slack-sdk and slack-bolt. "
|
|
48
|
+
"Install with: pip install agent-tether[slack]"
|
|
49
|
+
) from None
|
|
50
|
+
if name == "DiscordBridge":
|
|
51
|
+
try:
|
|
52
|
+
from agent_tether.platforms.discord.bridge import DiscordBridge
|
|
53
|
+
|
|
54
|
+
return DiscordBridge
|
|
55
|
+
except ImportError:
|
|
56
|
+
raise ImportError(
|
|
57
|
+
"DiscordBridge requires discord.py. "
|
|
58
|
+
"Install with: pip install agent-tether[discord]"
|
|
59
|
+
) from None
|
|
60
|
+
if name == "runner":
|
|
61
|
+
from agent_tether import runner as runner_module
|
|
62
|
+
|
|
63
|
+
return runner_module
|
|
64
|
+
raise AttributeError(f"module 'agent_tether' has no attribute {name!r}")
|
agent_tether/approval.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Auto-approve engine with timed permissions.
|
|
2
|
+
|
|
3
|
+
Manages per-thread, per-tool, and per-directory auto-approve timers.
|
|
4
|
+
When a human grants a timed permission (e.g., "Allow All for 30m"),
|
|
5
|
+
subsequent approval requests are resolved automatically until the
|
|
6
|
+
timer expires.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
_DEFAULT_DURATION_S = 30 * 60 # 30 minutes
|
|
15
|
+
_DEFAULT_NEVER_AUTO_APPROVE = frozenset({"task", "enterplanmode", "exitplanmode"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoApproveEngine:
|
|
19
|
+
"""Timed auto-approve engine.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
duration_s: How long timers last in seconds (default 30 minutes).
|
|
23
|
+
never_auto_approve: Tool name prefixes that are never auto-approved.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
duration_s: int = _DEFAULT_DURATION_S,
|
|
30
|
+
never_auto_approve: set[str] | frozenset[str] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._duration_s = duration_s
|
|
33
|
+
self._never: frozenset[str] = (
|
|
34
|
+
frozenset(never_auto_approve)
|
|
35
|
+
if never_auto_approve is not None
|
|
36
|
+
else _DEFAULT_NEVER_AUTO_APPROVE
|
|
37
|
+
)
|
|
38
|
+
# Per-thread allow-all: thread_id → expiry timestamp
|
|
39
|
+
self._allow_all_until: dict[str, float] = {}
|
|
40
|
+
# Per-thread per-tool: thread_id → {tool_name → expiry}
|
|
41
|
+
self._allow_tool_until: dict[str, dict[str, float]] = {}
|
|
42
|
+
# Per-directory: normalised_dir → expiry timestamp
|
|
43
|
+
self._allow_dir_until: dict[str, float] = {}
|
|
44
|
+
# Thread → directory association
|
|
45
|
+
self._thread_directory: dict[str, str] = {}
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Configuration
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def associate_directory(self, thread_id: str, directory: str) -> None:
|
|
52
|
+
"""Associate a thread with a directory path (for directory-scoped timers)."""
|
|
53
|
+
self._thread_directory[thread_id] = os.path.normpath(directory)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Check
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def check(self, thread_id: str, tool_name: str) -> str | None:
|
|
60
|
+
"""Check if a tool request should be auto-approved.
|
|
61
|
+
|
|
62
|
+
Returns a reason string if auto-approved, or None if human review
|
|
63
|
+
is required.
|
|
64
|
+
"""
|
|
65
|
+
norm = (tool_name or "").strip().lower()
|
|
66
|
+
if any(norm.startswith(prefix) for prefix in self._never):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
now = time.time()
|
|
70
|
+
|
|
71
|
+
# Per-thread allow-all
|
|
72
|
+
if now < self._allow_all_until.get(thread_id, 0):
|
|
73
|
+
return "Allow All"
|
|
74
|
+
|
|
75
|
+
# Per-thread per-tool
|
|
76
|
+
tool_expiry = self._allow_tool_until.get(thread_id, {}).get(tool_name, 0)
|
|
77
|
+
if now < tool_expiry:
|
|
78
|
+
return f"Allow {tool_name}"
|
|
79
|
+
|
|
80
|
+
# Per-directory
|
|
81
|
+
return self._check_directory(thread_id, now)
|
|
82
|
+
|
|
83
|
+
def _check_directory(self, thread_id: str, now: float) -> str | None:
|
|
84
|
+
"""Check directory-scoped auto-approve timers."""
|
|
85
|
+
if not self._allow_dir_until:
|
|
86
|
+
return None
|
|
87
|
+
thread_dir = self._thread_directory.get(thread_id)
|
|
88
|
+
if not thread_dir:
|
|
89
|
+
return None
|
|
90
|
+
for allowed_dir, expiry in self._allow_dir_until.items():
|
|
91
|
+
if now >= expiry:
|
|
92
|
+
continue
|
|
93
|
+
if thread_dir == allowed_dir or thread_dir.startswith(allowed_dir + os.sep):
|
|
94
|
+
short = os.path.basename(allowed_dir) or allowed_dir
|
|
95
|
+
return f"Allow dir {short}"
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Set timers
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def set_allow_all(self, thread_id: str) -> None:
|
|
103
|
+
"""Enable auto-approve for all tools on this thread."""
|
|
104
|
+
self._allow_all_until[thread_id] = time.time() + self._duration_s
|
|
105
|
+
|
|
106
|
+
def set_allow_tool(self, thread_id: str, tool_name: str) -> None:
|
|
107
|
+
"""Enable auto-approve for a specific tool on this thread."""
|
|
108
|
+
self._allow_tool_until.setdefault(thread_id, {})[tool_name] = (
|
|
109
|
+
time.time() + self._duration_s
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def set_allow_directory(self, directory: str) -> None:
|
|
113
|
+
"""Enable auto-approve for all threads in a directory."""
|
|
114
|
+
norm = os.path.normpath(directory)
|
|
115
|
+
self._allow_dir_until[norm] = time.time() + self._duration_s
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Cleanup
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Accessors
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def get_directory(self, thread_id: str) -> str | None:
|
|
126
|
+
"""Get the directory associated with a thread, or None."""
|
|
127
|
+
return self._thread_directory.get(thread_id)
|
|
128
|
+
|
|
129
|
+
def is_never_approved(self, tool_name: str) -> bool:
|
|
130
|
+
"""Check if a tool name matches the never-auto-approve set."""
|
|
131
|
+
norm = (tool_name or "").strip().lower()
|
|
132
|
+
return any(norm.startswith(prefix) for prefix in self._never)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
# Cleanup
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def remove_thread(self, thread_id: str) -> None:
|
|
139
|
+
"""Clean up all state for a thread."""
|
|
140
|
+
self._allow_all_until.pop(thread_id, None)
|
|
141
|
+
self._allow_tool_until.pop(thread_id, None)
|
|
142
|
+
self._thread_directory.pop(thread_id, None)
|
agent_tether/batching.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Notification batcher for auto-approve events.
|
|
2
|
+
|
|
3
|
+
Collects auto-approve notifications per thread and flushes them
|
|
4
|
+
as a single batched message after a short delay, so rapid-fire
|
|
5
|
+
approvals don't flood the chat.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotificationBatcher:
|
|
15
|
+
"""Batches notifications per thread and flushes after a delay.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
flush_callback: Async function called with ``(thread_id, items)``
|
|
19
|
+
when the batch is ready. ``items`` is a list of
|
|
20
|
+
``(tool_name, reason)`` tuples.
|
|
21
|
+
flush_delay: Seconds to wait before flushing (default 1.5).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
flush_callback: Callable[[str, list[tuple[str, str]]], Awaitable[None]],
|
|
27
|
+
*,
|
|
28
|
+
flush_delay: float = 1.5,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._flush_callback = flush_callback
|
|
31
|
+
self._flush_delay = flush_delay
|
|
32
|
+
self._buffer: dict[str, list[tuple[str, str]]] = {}
|
|
33
|
+
self._tasks: dict[str, asyncio.Task] = {}
|
|
34
|
+
|
|
35
|
+
def add(self, thread_id: str, tool_name: str, reason: str) -> None:
|
|
36
|
+
"""Buffer a notification. Resets the flush timer."""
|
|
37
|
+
self._buffer.setdefault(thread_id, []).append((tool_name, reason))
|
|
38
|
+
|
|
39
|
+
# Cancel existing timer and start a new one
|
|
40
|
+
existing = self._tasks.pop(thread_id, None)
|
|
41
|
+
if existing:
|
|
42
|
+
existing.cancel()
|
|
43
|
+
|
|
44
|
+
self._tasks[thread_id] = asyncio.create_task(self._flush_after_delay(thread_id))
|
|
45
|
+
|
|
46
|
+
async def _flush_after_delay(self, thread_id: str) -> None:
|
|
47
|
+
"""Wait then flush buffered notifications."""
|
|
48
|
+
try:
|
|
49
|
+
await asyncio.sleep(self._flush_delay)
|
|
50
|
+
except asyncio.CancelledError:
|
|
51
|
+
return
|
|
52
|
+
self._tasks.pop(thread_id, None)
|
|
53
|
+
items = self._buffer.pop(thread_id, [])
|
|
54
|
+
if items:
|
|
55
|
+
await self._flush_callback(thread_id, items)
|
|
56
|
+
|
|
57
|
+
def remove_thread(self, thread_id: str) -> None:
|
|
58
|
+
"""Cancel pending flush and discard buffer for a thread."""
|
|
59
|
+
task = self._tasks.pop(thread_id, None)
|
|
60
|
+
if task:
|
|
61
|
+
task.cancel()
|
|
62
|
+
self._buffer.pop(thread_id, None)
|
agent_tether/debounce.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Error notification debouncing.
|
|
2
|
+
|
|
3
|
+
Prevents flooding chat threads with repeated error messages
|
|
4
|
+
by enforcing a minimum interval between error notifications
|
|
5
|
+
per thread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorDebouncer:
|
|
14
|
+
"""Debounces error notifications per thread.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
debounce_seconds: Minimum seconds between error notifications
|
|
18
|
+
for the same thread. 0 means no debouncing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *, debounce_seconds: int = 0) -> None:
|
|
22
|
+
self._debounce_s = debounce_seconds
|
|
23
|
+
self._last_sent: dict[str, float] = {}
|
|
24
|
+
|
|
25
|
+
def should_send(self, thread_id: str) -> bool:
|
|
26
|
+
"""Return True if an error notification should be sent now."""
|
|
27
|
+
if self._debounce_s <= 0:
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
now = time.time()
|
|
31
|
+
last = self._last_sent.get(thread_id)
|
|
32
|
+
if last is not None and (now - last) < self._debounce_s:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
self._last_sent[thread_id] = now
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def remove_thread(self, thread_id: str) -> None:
|
|
39
|
+
"""Clean up state for a thread."""
|
|
40
|
+
self._last_sent.pop(thread_id, None)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Formatting utilities for tool input and human-readable output.
|
|
2
|
+
|
|
3
|
+
Provides functions to convert JSON tool input dicts into readable
|
|
4
|
+
text suitable for chat platforms, with smart truncation and key
|
|
5
|
+
humanization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Key / value humanization
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
_ACRONYMS = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"id",
|
|
20
|
+
"url",
|
|
21
|
+
"api",
|
|
22
|
+
"sdk",
|
|
23
|
+
"http",
|
|
24
|
+
"https",
|
|
25
|
+
"cli",
|
|
26
|
+
"ui",
|
|
27
|
+
"sse",
|
|
28
|
+
"mcp",
|
|
29
|
+
"json",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def humanize_key(key: str) -> str:
|
|
35
|
+
"""Convert a snake_case key into a human-friendly label.
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
>>> humanize_key("output_mode")
|
|
39
|
+
'Output mode'
|
|
40
|
+
>>> humanize_key("session_id")
|
|
41
|
+
'Session ID'
|
|
42
|
+
"""
|
|
43
|
+
if not key or "_" not in key:
|
|
44
|
+
return key
|
|
45
|
+
|
|
46
|
+
parts = [p for p in key.strip().split("_") if p]
|
|
47
|
+
if not parts:
|
|
48
|
+
return key
|
|
49
|
+
|
|
50
|
+
out: list[str] = []
|
|
51
|
+
for i, p in enumerate(parts):
|
|
52
|
+
low = p.lower()
|
|
53
|
+
if low in _ACRONYMS:
|
|
54
|
+
out.append(low.upper())
|
|
55
|
+
elif i == 0:
|
|
56
|
+
out.append(low[:1].upper() + low[1:])
|
|
57
|
+
else:
|
|
58
|
+
out.append(low)
|
|
59
|
+
return " ".join(out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def humanize_enum_value(value: object) -> str:
|
|
63
|
+
"""Humanize enum-ish snake_case values like ``files_with_matches``.
|
|
64
|
+
|
|
65
|
+
Only touches values that look like enums; paths and commands are
|
|
66
|
+
left alone.
|
|
67
|
+
"""
|
|
68
|
+
s = str(value)
|
|
69
|
+
if "_" not in s:
|
|
70
|
+
return s
|
|
71
|
+
if not re.fullmatch(r"[a-z0-9_]+", s):
|
|
72
|
+
return s
|
|
73
|
+
parts = [p for p in s.split("_") if p]
|
|
74
|
+
if not parts:
|
|
75
|
+
return s
|
|
76
|
+
out: list[str] = []
|
|
77
|
+
for i, p in enumerate(parts):
|
|
78
|
+
low = p.lower()
|
|
79
|
+
if low == "id":
|
|
80
|
+
out.append("ID")
|
|
81
|
+
elif i == 0:
|
|
82
|
+
out.append(low[:1].upper() + low[1:])
|
|
83
|
+
else:
|
|
84
|
+
out.append(low)
|
|
85
|
+
return " ".join(out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Tool input formatting
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
_PATH_KEYS = frozenset({"file_path", "path", "notebook_path"})
|
|
93
|
+
_CODE_BLOCK_KEYS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"command",
|
|
96
|
+
"old_string",
|
|
97
|
+
"new_string",
|
|
98
|
+
"content",
|
|
99
|
+
"new_source",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def format_tool_input(
|
|
105
|
+
raw: str,
|
|
106
|
+
*,
|
|
107
|
+
truncate: int = 400,
|
|
108
|
+
truncate_code: int = 1400,
|
|
109
|
+
max_chars: int = 2000,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Format a tool_input JSON string as readable markdown for chat platforms.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
raw: JSON string (or plain text) of the tool input.
|
|
115
|
+
truncate: Max chars per value (non-code fields).
|
|
116
|
+
truncate_code: Max chars for code-block fields.
|
|
117
|
+
max_chars: Total output character budget.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Formatted markdown string.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
obj = json.loads(raw) if isinstance(raw, str) else raw
|
|
124
|
+
except Exception:
|
|
125
|
+
return str(raw)
|
|
126
|
+
|
|
127
|
+
if not isinstance(obj, dict):
|
|
128
|
+
return str(raw)
|
|
129
|
+
|
|
130
|
+
lines: list[str] = []
|
|
131
|
+
total = 0
|
|
132
|
+
for key, value in obj.items():
|
|
133
|
+
key_s = str(key)
|
|
134
|
+
label = humanize_key(key_s)
|
|
135
|
+
|
|
136
|
+
if isinstance(value, (dict, list)):
|
|
137
|
+
v = json.dumps(value, ensure_ascii=True)
|
|
138
|
+
else:
|
|
139
|
+
v = humanize_enum_value(value)
|
|
140
|
+
|
|
141
|
+
limit = truncate_code if key_s in _CODE_BLOCK_KEYS else truncate
|
|
142
|
+
if len(v) > limit:
|
|
143
|
+
v = v[:limit] + "..."
|
|
144
|
+
|
|
145
|
+
# Prevent closing a code block early.
|
|
146
|
+
v = v.replace("```", "``\\`")
|
|
147
|
+
|
|
148
|
+
if key_s in _PATH_KEYS:
|
|
149
|
+
part = f"{label}: `{v}`"
|
|
150
|
+
elif key_s in _CODE_BLOCK_KEYS:
|
|
151
|
+
part = f"{label}:\n```\n{v}\n```"
|
|
152
|
+
else:
|
|
153
|
+
part = f"{label}: {v}"
|
|
154
|
+
|
|
155
|
+
if total + len(part) > max_chars and lines:
|
|
156
|
+
lines.append("...(truncated)")
|
|
157
|
+
break
|
|
158
|
+
lines.append(part)
|
|
159
|
+
total += len(part) + 1
|
|
160
|
+
|
|
161
|
+
return "\n".join(lines).strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def chunk_message(text: str, limit: int = 4096) -> list[str]:
|
|
165
|
+
"""Split a message into chunks at a character limit.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
text: Text to chunk.
|
|
169
|
+
limit: Maximum characters per chunk.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of text chunks.
|
|
173
|
+
"""
|
|
174
|
+
if len(text) <= limit:
|
|
175
|
+
return [text]
|
|
176
|
+
return [text[i : i + limit] for i in range(0, len(text), limit)]
|
agent_tether/models.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Core data models for agent-tether."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Handler type aliases
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
InputHandler = Callable[[str, str, str | None], Awaitable[None]]
|
|
16
|
+
"""(thread_id, text, username) → None"""
|
|
17
|
+
|
|
18
|
+
ApprovalHandler = Callable[..., Awaitable[None]]
|
|
19
|
+
"""(thread_id, request_id, approved, reason=None, timer=None) → None
|
|
20
|
+
|
|
21
|
+
timer: None | "all" | "dir" | tool_name
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
CommandHandler = Callable[[str, str, str], Awaitable[str | None]]
|
|
25
|
+
"""(thread_id, command, args) → optional response text"""
|
|
26
|
+
|
|
27
|
+
StatusHandler = Callable[[], Awaitable[str]]
|
|
28
|
+
"""() → status text to display"""
|
|
29
|
+
|
|
30
|
+
StopHandler = Callable[[str], Awaitable[str | None]]
|
|
31
|
+
"""(thread_id) → optional confirmation text"""
|
|
32
|
+
|
|
33
|
+
UsageHandler = Callable[[str], Awaitable[str | None]]
|
|
34
|
+
"""(thread_id) → optional usage text"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Handlers
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Handlers:
|
|
44
|
+
"""Callbacks the consumer provides to handle platform events.
|
|
45
|
+
|
|
46
|
+
All handlers are optional. If a handler is not set, the corresponding
|
|
47
|
+
event is silently ignored.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
on_input: InputHandler | None = None
|
|
51
|
+
"""Human sent a text message in a thread."""
|
|
52
|
+
|
|
53
|
+
on_approval_response: ApprovalHandler | None = None
|
|
54
|
+
"""Human responded to an approval/permission request."""
|
|
55
|
+
|
|
56
|
+
on_command: CommandHandler | None = None
|
|
57
|
+
"""Catch-all for unrecognized commands. Return text to reply, or None."""
|
|
58
|
+
|
|
59
|
+
on_status_request: StatusHandler | None = None
|
|
60
|
+
"""Handle /status or !status. Return text to display."""
|
|
61
|
+
|
|
62
|
+
on_stop_request: StopHandler | None = None
|
|
63
|
+
"""Handle /stop or !stop. Return text to confirm, or None."""
|
|
64
|
+
|
|
65
|
+
on_usage_request: UsageHandler | None = None
|
|
66
|
+
"""Handle /usage or !usage. Return text to display, or None."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Commands
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CommandDef:
|
|
76
|
+
"""Definition of a custom command.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
description: Short help text shown in /help output.
|
|
80
|
+
handler: Async function ``(thread_id, args) → str | None``.
|
|
81
|
+
Return text to reply, or None for no reply.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
description: str
|
|
85
|
+
handler: Callable[[str, str], Awaitable[str | None]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Approval / Choice requests
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ApprovalRequest(BaseModel):
|
|
94
|
+
"""An approval or choice request sent to a human via a chat thread.
|
|
95
|
+
|
|
96
|
+
For permission requests (kind="permission"), the human is asked to
|
|
97
|
+
Allow or Deny a tool invocation.
|
|
98
|
+
|
|
99
|
+
For choice requests (kind="choice"), the human picks from a list of
|
|
100
|
+
options (e.g., selecting an environment, confirming a plan).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
kind: Literal["permission", "choice"] = "permission"
|
|
104
|
+
request_id: str
|
|
105
|
+
title: str
|
|
106
|
+
description: str
|
|
107
|
+
options: list[str] = field(default_factory=lambda: ["Allow", "Deny"])
|
|
108
|
+
timeout_s: int = 300
|
|
File without changes
|