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,598 @@
1
+ """Abstract base class for platform bridges.
2
+
3
+ BridgeBase provides the shared machinery for all platform bridges:
4
+ approval flow, auto-approve engine, command dispatch, notification
5
+ batching, error debouncing, and thread state. Platform implementations
6
+ override the ``_platform_*`` methods.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import re
14
+ from abc import ABC, abstractmethod
15
+ from pathlib import Path
16
+
17
+ from agent_tether.approval import AutoApproveEngine
18
+ from agent_tether.batching import NotificationBatcher
19
+ from agent_tether.debounce import ErrorDebouncer
20
+ from agent_tether.formatting import format_tool_input
21
+ from agent_tether.models import ApprovalRequest, CommandDef, Handlers
22
+ from agent_tether.state import ThreadState
23
+
24
+ logger = logging.getLogger("agent_tether.bridge")
25
+
26
+ _STATE_EMOJI: dict[str, str] = {
27
+ "running": "🔄",
28
+ "waiting": "📝",
29
+ "error": "❌",
30
+ "done": "✅",
31
+ "thinking": "💭",
32
+ "executing": "⚙️",
33
+ }
34
+
35
+
36
+ class BridgeBase(ABC):
37
+ """Abstract base class for platform bridges.
38
+
39
+ Consumers interact through the public API (``create_thread``,
40
+ ``send_output``, ``send_approval_request``, etc.). Platform events
41
+ (human messages, button clicks) are routed through the handlers.
42
+
43
+ Subclasses implement the ``_platform_*`` methods for
44
+ platform-specific behavior.
45
+
46
+ Args:
47
+ handlers: Callback handlers for platform events.
48
+ commands: Custom command definitions (name → CommandDef).
49
+ disabled_commands: Built-in command names to disable.
50
+ data_dir: Directory for persistent state files.
51
+ auto_approve_duration: Auto-approve timer duration in seconds.
52
+ never_auto_approve: Tool name prefixes that are never auto-approved.
53
+ flush_delay: Seconds before flushing batched notifications.
54
+ error_debounce_seconds: Minimum seconds between error notifications.
55
+ command_prefix: Command prefix for this platform (default "!").
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ handlers: Handlers,
61
+ *,
62
+ commands: dict[str, CommandDef] | None = None,
63
+ disabled_commands: set[str] | None = None,
64
+ data_dir: str | Path | None = None,
65
+ auto_approve_duration: int = 30 * 60,
66
+ never_auto_approve: set[str] | frozenset[str] | None = None,
67
+ flush_delay: float = 1.5,
68
+ error_debounce_seconds: int = 0,
69
+ command_prefix: str = "!",
70
+ ) -> None:
71
+ self._handlers = handlers
72
+ self._command_prefix = command_prefix
73
+ self._disabled_commands = disabled_commands or set()
74
+ self._stopped = asyncio.Event()
75
+
76
+ # Resolve data dir
77
+ if data_dir:
78
+ self._data_dir = Path(data_dir)
79
+ else:
80
+ self._data_dir = Path.home() / ".agent-tether"
81
+ self._data_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Core components
84
+ self._approval = AutoApproveEngine(
85
+ duration_s=auto_approve_duration,
86
+ never_auto_approve=never_auto_approve,
87
+ )
88
+ self._batcher = NotificationBatcher(
89
+ self._send_auto_approve_batch,
90
+ flush_delay=flush_delay,
91
+ )
92
+ self._debouncer = ErrorDebouncer(debounce_seconds=error_debounce_seconds)
93
+
94
+ # Pending permission requests: thread_id → ApprovalRequest
95
+ self._pending: dict[str, ApprovalRequest] = {}
96
+
97
+ # Command registry: built-in + custom
98
+ self._commands: dict[str, CommandDef] = {}
99
+ self._register_builtins()
100
+ if commands:
101
+ self._commands.update(commands)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Built-in commands
105
+ # ------------------------------------------------------------------
106
+
107
+ def _register_builtins(self) -> None:
108
+ """Register built-in commands (unless disabled)."""
109
+ builtins: dict[str, CommandDef] = {
110
+ "help": CommandDef(
111
+ description="Show available commands",
112
+ handler=self._builtin_help,
113
+ ),
114
+ "status": CommandDef(
115
+ description="Show status",
116
+ handler=self._builtin_status,
117
+ ),
118
+ "stop": CommandDef(
119
+ description="Stop / interrupt the agent",
120
+ handler=self._builtin_stop,
121
+ ),
122
+ "usage": CommandDef(
123
+ description="Show token usage and cost",
124
+ handler=self._builtin_usage,
125
+ ),
126
+ }
127
+ for name, cmd in builtins.items():
128
+ if name not in self._disabled_commands:
129
+ self._commands[name] = cmd
130
+
131
+ async def _builtin_help(self, thread_id: str, args: str) -> str | None:
132
+ """Auto-generate help text from the command registry."""
133
+ prefix = self._command_prefix
134
+ lines = ["Available commands:\n"]
135
+ for name, cmd in sorted(self._commands.items()):
136
+ lines.append(f" {prefix}{name} — {cmd.description}")
137
+ lines.append(f"\nSend a text message in a thread to forward it as input.")
138
+ return "\n".join(lines)
139
+
140
+ async def _builtin_status(self, thread_id: str, args: str) -> str | None:
141
+ if self._handlers.on_status_request:
142
+ return await self._handlers.on_status_request()
143
+ return None
144
+
145
+ async def _builtin_stop(self, thread_id: str, args: str) -> str | None:
146
+ if self._handlers.on_stop_request:
147
+ return await self._handlers.on_stop_request(thread_id)
148
+ return None
149
+
150
+ async def _builtin_usage(self, thread_id: str, args: str) -> str | None:
151
+ if self._handlers.on_usage_request:
152
+ return await self._handlers.on_usage_request(thread_id)
153
+ return None
154
+
155
+ # ------------------------------------------------------------------
156
+ # Public API — Lifecycle
157
+ # ------------------------------------------------------------------
158
+
159
+ async def start(self) -> None:
160
+ """Start the bridge (connects to the platform)."""
161
+ self._stopped.clear()
162
+ await self._platform_start()
163
+ logger.info("Bridge started: %s", type(self).__name__)
164
+
165
+ async def stop(self) -> None:
166
+ """Stop the bridge."""
167
+ await self._platform_stop()
168
+ self._stopped.set()
169
+ logger.info("Bridge stopped: %s", type(self).__name__)
170
+
171
+ async def wait_until_stopped(self) -> None:
172
+ """Block until ``stop()`` is called."""
173
+ await self._stopped.wait()
174
+
175
+ # ------------------------------------------------------------------
176
+ # Public API — Threads
177
+ # ------------------------------------------------------------------
178
+
179
+ async def create_thread(self, name: str, *, directory: str | None = None) -> str:
180
+ """Create a new thread on the platform.
181
+
182
+ Args:
183
+ name: Display name for the thread.
184
+ directory: Optional directory path for directory-scoped auto-approve.
185
+
186
+ Returns:
187
+ Platform-native thread ID as a string.
188
+ """
189
+ thread_id = await self._platform_create_thread(name)
190
+ self._thread_state.register(thread_id, name)
191
+ if directory:
192
+ self._approval.associate_directory(thread_id, directory)
193
+ logger.info("Thread created: %s (id=%s)", name, thread_id)
194
+ return thread_id
195
+
196
+ async def remove_thread(self, thread_id: str) -> None:
197
+ """Clean up all state for a thread."""
198
+ self._pending.pop(thread_id, None)
199
+ self._approval.remove_thread(thread_id)
200
+ self._batcher.remove_thread(thread_id)
201
+ self._debouncer.remove_thread(thread_id)
202
+ self._thread_state.unregister(thread_id)
203
+ logger.info("Thread removed: %s", thread_id)
204
+
205
+ # ------------------------------------------------------------------
206
+ # Public API — Output
207
+ # ------------------------------------------------------------------
208
+
209
+ async def send_output(self, thread_id: str, text: str) -> None:
210
+ """Send output text to a thread."""
211
+ await self._platform_send(thread_id, text)
212
+
213
+ async def send_status(self, thread_id: str, status: str) -> None:
214
+ """Send a status notification to a thread.
215
+
216
+ Respects error debouncing.
217
+ """
218
+ if status == "error" and not self._debouncer.should_send(thread_id):
219
+ return
220
+ emoji = _STATE_EMOJI.get(status, "ℹ️")
221
+ await self._platform_send(thread_id, f"{emoji} Status: {status}")
222
+
223
+ async def send_typing(self, thread_id: str) -> None:
224
+ """Show a typing indicator (if the platform supports it)."""
225
+ await self._platform_typing_start(thread_id)
226
+
227
+ async def send_typing_stopped(self, thread_id: str) -> None:
228
+ """Stop the typing indicator."""
229
+ await self._platform_typing_stop(thread_id)
230
+
231
+ # ------------------------------------------------------------------
232
+ # Public API — Approvals
233
+ # ------------------------------------------------------------------
234
+
235
+ async def send_approval_request(
236
+ self,
237
+ thread_id: str,
238
+ *,
239
+ request_id: str,
240
+ tool_name: str,
241
+ description: str,
242
+ ) -> None:
243
+ """Send an approval request to a thread.
244
+
245
+ If an auto-approve timer is active, the request is resolved
246
+ automatically and a batched notification is sent instead.
247
+
248
+ Args:
249
+ thread_id: Target thread.
250
+ request_id: Unique request identifier.
251
+ tool_name: Name of the tool requesting approval.
252
+ description: JSON string or text describing the tool input.
253
+ """
254
+ request = ApprovalRequest(
255
+ kind="permission",
256
+ request_id=request_id,
257
+ title=tool_name,
258
+ description=description,
259
+ options=["Allow", "Deny"],
260
+ )
261
+
262
+ # Check auto-approve
263
+ reason = self._approval.check(thread_id, tool_name)
264
+ if reason:
265
+ await self._auto_approve(thread_id, request, reason)
266
+ return
267
+
268
+ await self._platform_typing_stop(thread_id)
269
+ self._pending[thread_id] = request
270
+ formatted = format_tool_input(description)
271
+ await self._platform_send_approval(thread_id, request, formatted)
272
+
273
+ async def send_choice_request(
274
+ self,
275
+ thread_id: str,
276
+ *,
277
+ request_id: str,
278
+ title: str,
279
+ description: str,
280
+ options: list[str],
281
+ ) -> None:
282
+ """Send a choice request (multi-option question) to a thread.
283
+
284
+ Args:
285
+ thread_id: Target thread.
286
+ request_id: Unique request identifier.
287
+ title: Question title / header.
288
+ description: Question body text.
289
+ options: List of option labels.
290
+ """
291
+ request = ApprovalRequest(
292
+ kind="choice",
293
+ request_id=request_id,
294
+ title=title,
295
+ description=description,
296
+ options=options,
297
+ )
298
+ await self._platform_typing_stop(thread_id)
299
+ self._pending[thread_id] = request
300
+ await self._platform_send_choice(thread_id, request)
301
+
302
+ # ------------------------------------------------------------------
303
+ # Auto-approve internals
304
+ # ------------------------------------------------------------------
305
+
306
+ async def _auto_approve(self, thread_id: str, request: ApprovalRequest, reason: str) -> None:
307
+ """Handle an auto-approved request: notify handler + batch notification."""
308
+ if self._handlers.on_approval_response:
309
+ await self._handlers.on_approval_response(
310
+ thread_id, request.request_id, True, None, reason
311
+ )
312
+ self._batcher.add(thread_id, request.title, reason)
313
+
314
+ async def _send_auto_approve_batch(self, thread_id: str, items: list[tuple[str, str]]) -> None:
315
+ """Default auto-approve batch notification. Platforms may override."""
316
+ if len(items) == 1:
317
+ tool_name, reason = items[0]
318
+ text = f"✅ {tool_name} — auto-approved ({reason})"
319
+ else:
320
+ lines = [f"✅ Auto-approved {len(items)} tools:"]
321
+ for tool_name, _reason in items:
322
+ lines.append(f" • {tool_name}")
323
+ lines.append(f"({items[0][1]})")
324
+ text = "\n".join(lines)
325
+ await self._platform_send(thread_id, text)
326
+
327
+ # ------------------------------------------------------------------
328
+ # Incoming message dispatch (platforms call this)
329
+ # ------------------------------------------------------------------
330
+
331
+ async def _dispatch_message(
332
+ self, thread_id: str, text: str, username: str | None = None
333
+ ) -> None:
334
+ """Route an incoming human message.
335
+
336
+ Called by platform implementations when a message arrives in a thread.
337
+ Handles: pending approvals/choices → commands → plain input.
338
+ """
339
+ stripped = text.strip()
340
+ if not stripped:
341
+ return
342
+
343
+ # 1. Check pending choice request
344
+ pending = self._pending.get(thread_id)
345
+ if pending and pending.kind == "choice":
346
+ selected = self._parse_choice_text(thread_id, stripped)
347
+ if selected:
348
+ self._pending.pop(thread_id, None)
349
+ if self._handlers.on_approval_response:
350
+ await self._handlers.on_approval_response(
351
+ thread_id, pending.request_id, True, selected, None
352
+ )
353
+ await self._platform_send(thread_id, f"✅ Selected: {selected}")
354
+ return
355
+
356
+ # 2. Check pending approval request
357
+ if pending and pending.kind == "permission":
358
+ parsed = self._parse_approval_text(stripped)
359
+ if parsed is not None:
360
+ await self._handle_approval_parsed(thread_id, pending, parsed, username)
361
+ return
362
+
363
+ # 3. Check for commands
364
+ if stripped.startswith(self._command_prefix):
365
+ await self._dispatch_command(thread_id, stripped, username)
366
+ return
367
+
368
+ # 4. Plain input
369
+ if self._handlers.on_input:
370
+ await self._handlers.on_input(thread_id, stripped, username)
371
+
372
+ async def _dispatch_command(
373
+ self, thread_id: str, text: str, username: str | None = None
374
+ ) -> None:
375
+ """Parse and dispatch a command."""
376
+ # Strip prefix
377
+ without_prefix = text[len(self._command_prefix) :].strip()
378
+ parts = without_prefix.split(None, 1)
379
+ if not parts:
380
+ return
381
+ cmd_name = parts[0].lower()
382
+ args = parts[1].strip() if len(parts) > 1 else ""
383
+
384
+ cmd = self._commands.get(cmd_name)
385
+ if cmd:
386
+ try:
387
+ reply = await cmd.handler(thread_id, args)
388
+ if reply:
389
+ await self._platform_send(thread_id, reply)
390
+ except Exception:
391
+ logger.exception("Command %s failed", cmd_name)
392
+ await self._platform_send(thread_id, f"Command failed: {cmd_name}")
393
+ return
394
+
395
+ # Catch-all handler
396
+ if self._handlers.on_command:
397
+ try:
398
+ reply = await self._handlers.on_command(thread_id, cmd_name, args)
399
+ if reply:
400
+ await self._platform_send(thread_id, reply)
401
+ except Exception:
402
+ logger.exception("Command handler failed for %s", cmd_name)
403
+ return
404
+
405
+ await self._platform_send(
406
+ thread_id,
407
+ f"Unknown command: {self._command_prefix}{cmd_name}\n"
408
+ f"Use {self._command_prefix}help for available commands.",
409
+ )
410
+
411
+ # ------------------------------------------------------------------
412
+ # Approval text parsing
413
+ # ------------------------------------------------------------------
414
+
415
+ @staticmethod
416
+ def _parse_approval_text(text: str) -> dict | None:
417
+ """Parse a text message as an approval response.
418
+
419
+ Returns a dict with keys: allow (bool), reason (str|None), timer (str|None)
420
+ or None if the text is not an approval command.
421
+ """
422
+ stripped = text.strip()
423
+ lower = stripped.lower()
424
+
425
+ # Common synonyms
426
+ if lower in ("proceed", "continue", "start", "go", "ok", "okay"):
427
+ return {"allow": True, "reason": None, "timer": None}
428
+ if lower in ("cancel", "stop", "abort"):
429
+ return {"allow": False, "reason": None, "timer": None}
430
+
431
+ # "allow all"
432
+ if lower == "allow all":
433
+ return {"allow": True, "reason": None, "timer": "all"}
434
+
435
+ # "allow dir"
436
+ if lower == "allow dir":
437
+ return {"allow": True, "reason": None, "timer": "dir"}
438
+
439
+ # "allow <tool>" (but not bare "allow")
440
+ if lower.startswith("allow ") and lower != "allow all":
441
+ rest = stripped[6:].strip()
442
+ if rest:
443
+ return {"allow": True, "reason": None, "timer": rest}
444
+
445
+ # Bare allow/approve/yes
446
+ if lower in ("allow", "approve", "yes"):
447
+ return {"allow": True, "reason": None, "timer": None}
448
+
449
+ # "deny: reason" or "reject: reason"
450
+ if lower.startswith(("deny:", "reject:", "no:")):
451
+ sep = stripped.index(":")
452
+ reason = stripped[sep + 1 :].strip()
453
+ return {"allow": False, "reason": reason or None, "timer": None}
454
+
455
+ # "deny reason" (multi-word)
456
+ if lower.startswith(("deny ", "reject ")):
457
+ first_space = stripped.index(" ")
458
+ reason = stripped[first_space + 1 :].strip()
459
+ if reason:
460
+ return {"allow": False, "reason": reason, "timer": None}
461
+
462
+ # Bare deny/reject/no
463
+ if lower in ("deny", "reject", "no"):
464
+ return {"allow": False, "reason": None, "timer": None}
465
+
466
+ return None
467
+
468
+ def _parse_choice_text(self, thread_id: str, text: str) -> str | None:
469
+ """Parse a text message as a choice selection.
470
+
471
+ Supports numeric (1-indexed) or exact label match.
472
+ """
473
+ pending = self._pending.get(thread_id)
474
+ if not pending or pending.kind != "choice":
475
+ return None
476
+
477
+ stripped = text.strip()
478
+ if not stripped:
479
+ return None
480
+
481
+ # Numeric selection
482
+ if stripped.isdigit():
483
+ idx = int(stripped) - 1
484
+ if 0 <= idx < len(pending.options):
485
+ return pending.options[idx]
486
+ return None
487
+
488
+ # Label match (case-insensitive)
489
+ lowered = stripped.casefold()
490
+ for opt in pending.options:
491
+ if opt.casefold() == lowered:
492
+ return opt
493
+ return None
494
+
495
+ async def _handle_approval_parsed(
496
+ self,
497
+ thread_id: str,
498
+ request: ApprovalRequest,
499
+ parsed: dict,
500
+ username: str | None = None,
501
+ ) -> None:
502
+ """Handle a parsed approval text response."""
503
+ allow = parsed["allow"]
504
+ reason = parsed.get("reason")
505
+ timer = parsed.get("timer")
506
+
507
+ if allow:
508
+ if timer == "all":
509
+ self._approval.set_allow_all(thread_id)
510
+ elif timer == "dir":
511
+ directory = self._approval.get_directory(thread_id)
512
+ if directory:
513
+ self._approval.set_allow_directory(directory)
514
+ else:
515
+ self._approval.set_allow_all(thread_id)
516
+ elif timer:
517
+ self._approval.set_allow_tool(thread_id, timer)
518
+
519
+ self._pending.pop(thread_id, None)
520
+
521
+ if self._handlers.on_approval_response:
522
+ await self._handlers.on_approval_response(
523
+ thread_id, request.request_id, allow, reason, timer
524
+ )
525
+
526
+ # Send confirmation
527
+ if allow:
528
+ msg = "Approved"
529
+ if timer == "all":
530
+ msg = "Allow All (30m)"
531
+ elif timer == "dir":
532
+ msg = "Allow dir (30m)"
533
+ elif timer:
534
+ msg = f"Allow {timer} (30m)"
535
+ display = f"✅ {msg}"
536
+ if username:
537
+ display += f" by {username}"
538
+ await self._platform_send(thread_id, display)
539
+ else:
540
+ msg = f"Denied: {reason}" if reason else "Denied"
541
+ display = f"❌ {msg}"
542
+ if username:
543
+ display += f" by {username}"
544
+ await self._platform_send(thread_id, display)
545
+
546
+ # ------------------------------------------------------------------
547
+ # Thread state (initialized lazily by subclass or in start)
548
+ # ------------------------------------------------------------------
549
+
550
+ @property
551
+ def _thread_state(self) -> ThreadState:
552
+ """Thread state instance. Subclasses should set ``_thread_state_instance``."""
553
+ if not hasattr(self, "_thread_state_instance") or self._thread_state_instance is None:
554
+ platform_name = type(self).__name__.lower().replace("bridge", "")
555
+ path = self._data_dir / f"{platform_name}_threads.json"
556
+ self._thread_state_instance = ThreadState(path)
557
+ self._thread_state_instance.load()
558
+ return self._thread_state_instance
559
+
560
+ # ------------------------------------------------------------------
561
+ # Platform abstract methods
562
+ # ------------------------------------------------------------------
563
+
564
+ @abstractmethod
565
+ async def _platform_start(self) -> None:
566
+ """Start the platform client/bot."""
567
+
568
+ @abstractmethod
569
+ async def _platform_stop(self) -> None:
570
+ """Stop the platform client/bot."""
571
+
572
+ @abstractmethod
573
+ async def _platform_send(self, thread_id: str, text: str, **kwargs) -> None:
574
+ """Send a text message to a thread."""
575
+
576
+ @abstractmethod
577
+ async def _platform_create_thread(self, name: str) -> str:
578
+ """Create a thread on the platform. Return the platform-native thread ID."""
579
+
580
+ @abstractmethod
581
+ async def _platform_send_approval(
582
+ self, thread_id: str, request: ApprovalRequest, formatted_description: str
583
+ ) -> None:
584
+ """Send an approval request with platform-specific UI (buttons, text, etc.)."""
585
+
586
+ @abstractmethod
587
+ async def _platform_send_choice(self, thread_id: str, request: ApprovalRequest) -> None:
588
+ """Send a choice request with platform-specific UI."""
589
+
590
+ # ------------------------------------------------------------------
591
+ # Platform optional methods (override as needed)
592
+ # ------------------------------------------------------------------
593
+
594
+ async def _platform_typing_start(self, thread_id: str) -> None:
595
+ """Show a typing indicator. Override if the platform supports it."""
596
+
597
+ async def _platform_typing_stop(self, thread_id: str) -> None:
598
+ """Stop the typing indicator. Override if the platform supports it."""
File without changes