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.
@@ -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}")
@@ -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)
@@ -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)
@@ -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