agent-core-webcam 0.8.2__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 @@
1
+ """agent-core-webcam — Webcam capture endpoint for agent_core."""
@@ -0,0 +1,50 @@
1
+ """Append-only JSONL audit log for webcam tool invocations.
2
+
3
+ Each ``capture_webcam_frame`` and ``list_cameras`` call writes one line.
4
+ Schema is documented in the design spec. Failures are swallowed so an
5
+ audit failure never breaks a capture.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from agent_core.audit import JsonlAuditLog
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AuditEvent:
21
+ """One line in the webcam audit log."""
22
+
23
+ timestamp: datetime
24
+ tool: str
25
+ result: str # "ok" | "error"
26
+ data: dict[str, Any]
27
+
28
+
29
+ class AuditLog(JsonlAuditLog[AuditEvent]):
30
+ """Append-only JSONL audit log."""
31
+
32
+ def __init__(self, path: Path) -> None:
33
+ super().__init__(path)
34
+
35
+ @staticmethod
36
+ def default_path(endpoint_name: str) -> Path:
37
+ """Returns ``~/.agent-core/webcam/<endpoint_name>/audit.jsonl``."""
38
+ return Path.home() / ".agent-core" / "webcam" / endpoint_name / "audit.jsonl"
39
+
40
+ def _serialize(self, event: AuditEvent) -> str:
41
+ payload = {
42
+ "timestamp": event.timestamp.isoformat(),
43
+ "tool": event.tool,
44
+ "result": event.result,
45
+ "data": event.data,
46
+ }
47
+ return json.dumps(payload, default=str, ensure_ascii=False)
48
+
49
+
50
+ __all__ = ["AuditEvent", "AuditLog"]
@@ -0,0 +1,419 @@
1
+ """WebcamEndpoint — bus endpoint that exposes capture tools via MCP.
2
+
3
+ Implements the standard Endpoint protocol but ``deliver`` is a no-op:
4
+ webcam is tool-only, no inbox, no agent-to-agent envelopes. The
5
+ endpoint exists so MCP tools have somewhere to live and config to read.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from agent_core_webcam.audit import AuditEvent, AuditLog
18
+ from agent_core_webcam.protocol import (
19
+ CameraBackend,
20
+ CameraBusyError,
21
+ CameraNotFoundError,
22
+ ReadTimeoutError,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from agent_core.bus.envelope import Envelope
27
+ from agent_core.bus.handle import BusHandle
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ def _to_tuple(value: tuple[int, int] | list[int]) -> tuple[int, int]:
33
+ if isinstance(value, tuple):
34
+ if len(value) != 2:
35
+ raise ValueError(f"resolution must be [width, height], got {value!r}")
36
+ return value
37
+ if isinstance(value, list) and len(value) == 2:
38
+ return (int(value[0]), int(value[1]))
39
+ raise ValueError(f"resolution must be [width, height], got {value!r}")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CaptureSuccess:
44
+ """Successful capture result."""
45
+
46
+ png_bytes: bytes
47
+ file_path: Path | None
48
+ metadata: dict
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CaptureError:
53
+ """Failed capture — message is the agent-readable string."""
54
+
55
+ message: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ListCamerasSuccess:
60
+ """list_cameras_safe success."""
61
+
62
+ cameras: list # list[CameraInfo] — avoid forward-ref import dance
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ListCamerasError:
67
+ """list_cameras_safe failure — message is the agent-readable string."""
68
+
69
+ message: str
70
+
71
+
72
+ class WebcamEndpoint:
73
+ """Tool-only bus endpoint backing the webcam MCP tool surface."""
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ name: str,
79
+ captures_root: Path | str | None = None,
80
+ audit_log_path: Path | str | None = None,
81
+ default_camera_index: int = 0,
82
+ default_resolution: tuple[int, int] | list[int] = (1280, 720),
83
+ max_resolution: tuple[int, int] | list[int] = (3840, 2160),
84
+ capture_timeout_seconds: float = 3.0,
85
+ enabled: bool = True,
86
+ camera_backend: CameraBackend | None = None,
87
+ ):
88
+ self.name = name
89
+ self.captures_root = (
90
+ Path(captures_root)
91
+ if captures_root is not None
92
+ else Path.home() / ".agent-core" / "webcam" / name
93
+ )
94
+ audit_path = (
95
+ Path(audit_log_path)
96
+ if audit_log_path is not None
97
+ else self.captures_root / "audit.jsonl"
98
+ )
99
+ self.audit_log = AuditLog(audit_path)
100
+ self.default_camera_index = default_camera_index
101
+ self.default_resolution = _to_tuple(default_resolution)
102
+ self.max_resolution = _to_tuple(max_resolution)
103
+ self.capture_timeout_seconds = capture_timeout_seconds
104
+ self.enabled = enabled
105
+ if camera_backend is None:
106
+ from agent_core_webcam.opencv_backend import OpenCVCameraBackend
107
+ camera_backend = OpenCVCameraBackend(timeout_seconds=capture_timeout_seconds)
108
+ self._backend: CameraBackend = camera_backend
109
+ self._handle: BusHandle | None = None
110
+ self._camera_locks: dict[int, asyncio.Lock] = {}
111
+ self._camera_locks_guard = asyncio.Lock()
112
+
113
+ async def start(self, bus: BusHandle) -> None:
114
+ self._handle = bus
115
+ log.info("WebcamEndpoint(name=%s) started; captures=%s", self.name, self.captures_root)
116
+
117
+ async def deliver(self, envelope: Envelope) -> None:
118
+ # Webcam is tool-only; envelopes addressed to us are unexpected.
119
+ # Log at debug, then ack so the bus doesn't redeliver or dead-letter.
120
+ log.debug(
121
+ "WebcamEndpoint(name=%s) ignoring delivered envelope %s", self.name, envelope.id
122
+ )
123
+ if self._handle is not None:
124
+ await self._handle.ack(envelope.id)
125
+
126
+ async def stop(self) -> None:
127
+ self._handle = None
128
+ log.info("WebcamEndpoint(name=%s) stopped", self.name)
129
+
130
+ async def _lock_for(self, camera_index: int) -> asyncio.Lock:
131
+ async with self._camera_locks_guard:
132
+ if camera_index not in self._camera_locks:
133
+ self._camera_locks[camera_index] = asyncio.Lock()
134
+ return self._camera_locks[camera_index]
135
+
136
+ async def capture_frame(
137
+ self,
138
+ *,
139
+ camera_index: int | None = None,
140
+ resolution: tuple[int, int] | list[int] | None = None,
141
+ save: bool = True,
142
+ note: str | None = None,
143
+ ) -> tuple[bytes, Path | None, dict]:
144
+ """Capture one frame; return (png_bytes, file_path, metadata).
145
+
146
+ Returns ``file_path=None`` when ``save=False``. Always appends an
147
+ audit entry. Errors raise — Task 7 maps them to user-facing
148
+ messages at the MCP boundary.
149
+ """
150
+ idx = camera_index if camera_index is not None else self.default_camera_index
151
+ res = _to_tuple(resolution) if resolution is not None else self.default_resolution
152
+ lock = await self._lock_for(idx)
153
+ async with lock:
154
+ # Enforce capture_timeout_seconds at the async layer: a hung
155
+ # backend read (OpenCV's cap.read() is uninterruptible) would
156
+ # otherwise hold this per-camera lock forever, wedging every future
157
+ # capture on this camera. wait_for releases the lock and surfaces a
158
+ # ReadTimeoutError; the orphaned worker thread is the lesser evil
159
+ # than a permanently-stuck endpoint.
160
+ try:
161
+ png_bytes = await asyncio.wait_for(
162
+ asyncio.to_thread(self._backend.capture, idx, res),
163
+ timeout=self.capture_timeout_seconds,
164
+ )
165
+ except TimeoutError as exc:
166
+ raise ReadTimeoutError(
167
+ f"camera {idx} did not return a frame within "
168
+ f"{self.capture_timeout_seconds}s"
169
+ ) from exc
170
+ timestamp = datetime.now(UTC).astimezone()
171
+ try:
172
+ all_cams = await asyncio.to_thread(self._backend.list_cameras)
173
+ cam_name = next((c.name for c in all_cams if c.index == idx), f"camera {idx}")
174
+ except Exception:
175
+ cam_name = f"camera {idx}"
176
+ file_path: Path | None = None
177
+ if save:
178
+ file_path = self.captures_root / timestamp.strftime("%Y-%m-%d") / (
179
+ timestamp.strftime("%H%M%S-") + f"{timestamp.microsecond // 1000:03d}.png"
180
+ )
181
+ await asyncio.to_thread(self._write_png, file_path, png_bytes)
182
+ meta = {
183
+ "camera_index": idx,
184
+ "camera_name": cam_name,
185
+ "resolution": res,
186
+ "timestamp": timestamp.isoformat(),
187
+ "filesize": len(png_bytes),
188
+ "file_path": str(file_path) if file_path else None,
189
+ }
190
+ await self.audit_log.write(
191
+ AuditEvent(
192
+ timestamp=timestamp,
193
+ tool="capture_webcam_frame",
194
+ result="ok",
195
+ data={
196
+ "camera_index": idx,
197
+ "camera_name": cam_name,
198
+ "resolution": list(res),
199
+ "save": save,
200
+ "note": note,
201
+ "file_path": str(file_path) if file_path else None,
202
+ "filesize": len(png_bytes),
203
+ },
204
+ )
205
+ )
206
+ return png_bytes, file_path, meta
207
+
208
+ async def capture_frame_safe(
209
+ self,
210
+ *,
211
+ camera_index: int | None = None,
212
+ resolution: tuple[int, int] | list[int] | None = None,
213
+ save: bool = True,
214
+ note: str | None = None,
215
+ ) -> CaptureSuccess | CaptureError:
216
+ """Like ``capture_frame`` but maps every error to a CaptureError + audit.
217
+
218
+ The MCP tool wrapper uses this — it never wants to raise into FastMCP.
219
+ """
220
+ if not self.enabled:
221
+ return await self._error(
222
+ user_message=(
223
+ "error: webcam endpoint is disabled (enabled=false in config). "
224
+ "Ask the operator if this is unexpected."
225
+ ),
226
+ audit_error="endpoint disabled",
227
+ camera_index=camera_index,
228
+ resolution=_to_tuple(resolution) if resolution is not None else None,
229
+ save=save,
230
+ note=note,
231
+ )
232
+
233
+ # Resolution validation + cap.
234
+ idx = camera_index if camera_index is not None else self.default_camera_index
235
+ try:
236
+ res = _to_tuple(resolution) if resolution is not None else self.default_resolution
237
+ except ValueError as exc:
238
+ return await self._error(
239
+ user_message=(
240
+ f"error: invalid resolution: {exc}. "
241
+ f"Expected [width, height] (a list of two ints)."
242
+ ),
243
+ audit_error=f"invalid resolution: {exc}",
244
+ camera_index=idx,
245
+ save=save,
246
+ note=note,
247
+ )
248
+ if res[0] > self.max_resolution[0] or res[1] > self.max_resolution[1]:
249
+ return await self._error(
250
+ user_message=(
251
+ f"error: requested resolution {res[0]}x{res[1]} exceeds "
252
+ f"configured max {self.max_resolution[0]}x{self.max_resolution[1]}."
253
+ ),
254
+ audit_error="resolution capped",
255
+ camera_index=idx,
256
+ resolution=res,
257
+ save=save,
258
+ note=note,
259
+ )
260
+
261
+ try:
262
+ png_bytes, file_path, meta = await self.capture_frame(
263
+ camera_index=idx,
264
+ resolution=res,
265
+ save=save,
266
+ note=note,
267
+ )
268
+ except CameraNotFoundError:
269
+ try:
270
+ cams = await asyncio.to_thread(self._backend.list_cameras)
271
+ available = [c.index for c in cams]
272
+ except Exception:
273
+ available = []
274
+ return await self._error(
275
+ user_message=(
276
+ f"error: no camera at index {idx} "
277
+ f"(host has {len(available)} cameras: indices {available}). "
278
+ f"Call list_cameras to see what's available."
279
+ ),
280
+ audit_error=f"camera {idx} not found",
281
+ camera_index=idx,
282
+ resolution=res,
283
+ save=save,
284
+ note=note,
285
+ )
286
+ except CameraBusyError:
287
+ try:
288
+ cams = await asyncio.to_thread(self._backend.list_cameras)
289
+ cam_name = next(
290
+ (c.name for c in cams if c.index == idx),
291
+ f"camera {idx}",
292
+ )
293
+ except Exception:
294
+ cam_name = f"camera {idx}"
295
+ return await self._error(
296
+ user_message=(
297
+ f"error: camera {idx} ({cam_name}) is busy. "
298
+ f"Likely in use by another application (Zoom, browser, etc.). "
299
+ f"Try again in a moment."
300
+ ),
301
+ audit_error="camera busy",
302
+ camera_index=idx,
303
+ resolution=res,
304
+ save=save,
305
+ note=note,
306
+ )
307
+ except ReadTimeoutError:
308
+ return await self._error(
309
+ user_message=(
310
+ f"error: camera opened but returned no frame within "
311
+ f"{self.capture_timeout_seconds}s. Camera may be initializing or obstructed."
312
+ ),
313
+ audit_error="read timeout",
314
+ camera_index=idx,
315
+ resolution=res,
316
+ save=save,
317
+ note=note,
318
+ )
319
+ except OSError as exc:
320
+ return await self._error(
321
+ user_message=(
322
+ f"error: capture succeeded but failed to write to disk: {exc}. "
323
+ f"Image is unavailable; retry or set save=false."
324
+ ),
325
+ audit_error=f"disk write failed: {exc}",
326
+ camera_index=idx,
327
+ resolution=res,
328
+ save=save,
329
+ note=note,
330
+ )
331
+ return CaptureSuccess(png_bytes=png_bytes, file_path=file_path, metadata=meta)
332
+
333
+ async def list_cameras_safe(self) -> ListCamerasSuccess | ListCamerasError:
334
+ """Enumerate cameras, return list with audit. Best-effort — never raises."""
335
+ if not self.enabled:
336
+ timestamp = datetime.now(UTC).astimezone()
337
+ await self.audit_log.write(
338
+ AuditEvent(
339
+ timestamp=timestamp,
340
+ tool="list_cameras",
341
+ result="error",
342
+ data={"error": "endpoint disabled"},
343
+ )
344
+ )
345
+ return ListCamerasError(
346
+ message=(
347
+ "error: webcam endpoint is disabled (enabled=false in config). "
348
+ "Ask the operator if this is unexpected."
349
+ )
350
+ )
351
+ try:
352
+ cams = await asyncio.to_thread(self._backend.list_cameras)
353
+ except Exception as exc:
354
+ timestamp = datetime.now(UTC).astimezone()
355
+ await self.audit_log.write(
356
+ AuditEvent(
357
+ timestamp=timestamp,
358
+ tool="list_cameras",
359
+ result="error",
360
+ data={"error": f"backend list_cameras failed: {exc}"},
361
+ )
362
+ )
363
+ return ListCamerasError(
364
+ message=(
365
+ f"error: failed to enumerate cameras: {exc}. "
366
+ f"Check that the camera driver is responsive."
367
+ )
368
+ )
369
+ timestamp = datetime.now(UTC).astimezone()
370
+ await self.audit_log.write(
371
+ AuditEvent(
372
+ timestamp=timestamp,
373
+ tool="list_cameras",
374
+ result="ok",
375
+ data={"camera_count": len(cams)},
376
+ )
377
+ )
378
+ return ListCamerasSuccess(cameras=list(cams))
379
+
380
+ async def _error(
381
+ self,
382
+ *,
383
+ user_message: str,
384
+ audit_error: str,
385
+ camera_index: int | None = None,
386
+ resolution: tuple[int, int] | None = None,
387
+ save: bool = True,
388
+ note: str | None = None,
389
+ ) -> CaptureError:
390
+ timestamp = datetime.now(UTC).astimezone()
391
+ await self.audit_log.write(
392
+ AuditEvent(
393
+ timestamp=timestamp,
394
+ tool="capture_webcam_frame",
395
+ result="error",
396
+ data={
397
+ "camera_index": camera_index,
398
+ "resolution": list(resolution) if resolution else None,
399
+ "save": save,
400
+ "note": note,
401
+ "error": audit_error,
402
+ },
403
+ )
404
+ )
405
+ return CaptureError(message=user_message)
406
+
407
+ @staticmethod
408
+ def _write_png(path: Path, data: bytes) -> None:
409
+ path.parent.mkdir(parents=True, exist_ok=True)
410
+ path.write_bytes(data)
411
+
412
+
413
+ __all__ = [
414
+ "CaptureError",
415
+ "CaptureSuccess",
416
+ "ListCamerasError",
417
+ "ListCamerasSuccess",
418
+ "WebcamEndpoint",
419
+ ]
@@ -0,0 +1,142 @@
1
+ """Deterministic camera backend for tests.
2
+
3
+ Mirrors ``OpenCVCameraBackend``'s behavior strictly — including refusing
4
+ argument shapes the real backend refuses (unknown camera index →
5
+ ``CameraNotFoundError``). Produces minimal valid PNGs without any
6
+ imaging work, so tests stay fast.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ import zlib
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from agent_core_webcam.protocol import (
17
+ CameraBusyError,
18
+ CameraInfo,
19
+ CameraNotFoundError,
20
+ ReadTimeoutError,
21
+ )
22
+
23
+
24
+ class _FluentMode:
25
+ """Descriptor that makes a mode-setter work both as a classmethod
26
+ (``FakeCameraBackend.with_busy(0)`` creates a fresh instance) and as
27
+ an instance method (``fake.with_busy(0)`` mutates + returns the
28
+ existing instance), so call-chains like
29
+ ``FakeCameraBackend.with_cameras([0,1]).with_busy(0)`` compose
30
+ correctly.
31
+ """
32
+
33
+ def __init__(self, attr: str) -> None:
34
+ self._attr = attr
35
+
36
+ def __set_name__(self, owner: type, name: str) -> None:
37
+ self._name = name
38
+
39
+ def __get__(self, obj: Any, objtype: type | None = None) -> Any:
40
+ attr = self._attr
41
+
42
+ if obj is None:
43
+ # Called on the class: FakeCameraBackend.with_busy(0)
44
+ def class_call(index: int) -> FakeCameraBackend:
45
+ inst = FakeCameraBackend()
46
+ getattr(inst, attr).add(index)
47
+ return inst
48
+
49
+ return class_call
50
+ else:
51
+ # Called on an instance: fake.with_busy(0)
52
+ def instance_call(index: int) -> FakeCameraBackend:
53
+ getattr(obj, attr).add(index)
54
+ return obj
55
+
56
+ return instance_call
57
+
58
+
59
+ @dataclass
60
+ class FakeCameraBackend:
61
+ """In-memory camera backend with composable failure modes.
62
+
63
+ Use the classmethods (``with_cameras``, ``with_busy``, etc.) to
64
+ configure modes. Each returns a new or existing ``FakeCameraBackend``
65
+ so test setup reads naturally.
66
+ """
67
+
68
+ available_indices: list[int] = field(default_factory=lambda: [0])
69
+ _busy: set[int] = field(default_factory=set)
70
+ _missing: set[int] = field(default_factory=set)
71
+ _timeout: set[int] = field(default_factory=set)
72
+
73
+ @classmethod
74
+ def with_cameras(cls, indices: list[int]) -> FakeCameraBackend:
75
+ return cls(available_indices=list(indices))
76
+
77
+ # Each _FluentMode below must have a matching `set[int]` field in the dataclass.
78
+ # Adding a new `with_X = _FluentMode("_X")` without also declaring
79
+ # `_X: set[int] = field(default_factory=set)` above will raise AttributeError
80
+ # on first use.
81
+ with_busy = _FluentMode("_busy")
82
+ with_missing = _FluentMode("_missing")
83
+ with_read_timeout = _FluentMode("_timeout")
84
+
85
+ def list_cameras(self) -> list[CameraInfo]:
86
+ """List available cameras with their status.
87
+
88
+ Cameras marked `with_missing(idx)` where idx is NOT in `available_indices`
89
+ are NOT enumerated — "missing" means "this index doesn't exist on the host,"
90
+ so the fake mirrors real OpenCV's silence about indices it cannot open.
91
+ Cameras marked `with_busy(idx)` where idx IS in `available_indices` DO
92
+ appear in the list with `available=False` — "busy" means "exists but currently
93
+ in use elsewhere."
94
+ """
95
+ out: list[CameraInfo] = []
96
+ for idx in self.available_indices:
97
+ available = idx not in self._busy and idx not in self._missing
98
+ out.append(CameraInfo(index=idx, name=f"Fake Camera {idx}", available=available))
99
+ return out
100
+
101
+ def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
102
+ if index in self._missing:
103
+ raise CameraNotFoundError(f"camera {index} marked missing")
104
+ if index not in self.available_indices:
105
+ raise CameraNotFoundError(f"camera {index} not in configured indices")
106
+ if index in self._busy:
107
+ raise CameraBusyError(f"camera {index} is busy")
108
+ if index in self._timeout:
109
+ raise ReadTimeoutError(f"camera {index} did not return a frame in time")
110
+ return _make_minimal_png(resolution)
111
+
112
+
113
+ def _make_minimal_png(resolution: tuple[int, int]) -> bytes:
114
+ """Produce a valid solid-gray PNG of the requested resolution.
115
+
116
+ Uses pure-Python PNG construction (no PIL/cv2 dependency) so the
117
+ fake stays light. The image is solid mid-gray RGB; tests only need
118
+ the bytes to be a valid PNG with the right dimensions.
119
+ """
120
+ width, height = resolution
121
+ # IHDR: width, height, bit depth=8, color type=2 (RGB), compression, filter, interlace
122
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
123
+ raw = b""
124
+ row_bytes = width * 3
125
+ for _ in range(height):
126
+ raw += b"\x00" + (b"\x80" * row_bytes) # filter byte + gray pixels
127
+ idat = zlib.compress(raw, 9)
128
+ return (
129
+ b"\x89PNG\r\n\x1a\n"
130
+ + _png_chunk(b"IHDR", ihdr)
131
+ + _png_chunk(b"IDAT", idat)
132
+ + _png_chunk(b"IEND", b"")
133
+ )
134
+
135
+
136
+ def _png_chunk(tag: bytes, data: bytes) -> bytes:
137
+ length = struct.pack(">I", len(data))
138
+ crc = struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
139
+ return length + tag + data + crc
140
+
141
+
142
+ __all__ = ["FakeCameraBackend"]
@@ -0,0 +1,96 @@
1
+ """Agent-facing MCP tool surface for the webcam endpoint.
2
+
3
+ Two tools: ``capture_webcam_frame`` and ``list_cameras``. Each returns
4
+ a list of MCP content blocks. On error, only TextContent is returned —
5
+ no image. The endpoint's ``capture_frame_safe`` / ``list_cameras_safe``
6
+ methods do the error mapping; this module just adapts the result type
7
+ to MCP content blocks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from mcp.types import ImageContent, TextContent
17
+
18
+ from agent_core_webcam.endpoint import (
19
+ CaptureError,
20
+ CaptureSuccess,
21
+ ListCamerasError,
22
+ ListCamerasSuccess,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from fastmcp import FastMCP
27
+
28
+ from agent_core_webcam.endpoint import WebcamEndpoint
29
+
30
+
31
+ def register_webcam_tools(*, mcp: FastMCP, endpoint: WebcamEndpoint) -> None:
32
+ """Register the two webcam tools on a FastMCP server."""
33
+
34
+ @mcp.tool(
35
+ name="capture_webcam_frame",
36
+ description=(
37
+ "Capture a single frame from a connected webcam. Returns the image "
38
+ "inline (so you can see it immediately) plus a saved file path you "
39
+ "can later attach to a Discord message, archive, or revisit. Use "
40
+ "camera_index=0 for the default camera; call list_cameras to "
41
+ "enumerate other devices."
42
+ ),
43
+ )
44
+ async def _capture(
45
+ camera_index: int = 0,
46
+ resolution: list[int] | None = None,
47
+ save: bool = True,
48
+ note: str | None = None,
49
+ ) -> list[Any]:
50
+ """Capture one frame; return [ImageContent, TextContent] on success."""
51
+ result = await endpoint.capture_frame_safe(
52
+ camera_index=camera_index,
53
+ resolution=list(resolution) if resolution else None,
54
+ save=save,
55
+ note=note,
56
+ )
57
+ if isinstance(result, CaptureError):
58
+ return [TextContent(type="text", text=result.message)]
59
+ assert isinstance(result, CaptureSuccess)
60
+ meta = result.metadata
61
+ text = (
62
+ f"Captured frame from camera {meta['camera_index']} ({meta['camera_name']}) "
63
+ f"at {meta['resolution'][0]}x{meta['resolution'][1]}.\n"
64
+ f"Path: {meta['file_path']}\n"
65
+ f"Timestamp: {meta['timestamp']}\n"
66
+ f"Filesize: {meta['filesize']} bytes"
67
+ )
68
+ return [
69
+ ImageContent(
70
+ type="image",
71
+ data=base64.b64encode(result.png_bytes).decode("ascii"),
72
+ mimeType="image/png",
73
+ ),
74
+ TextContent(type="text", text=text),
75
+ ]
76
+
77
+ @mcp.tool(
78
+ name="list_cameras",
79
+ description=(
80
+ "List all webcams detected on this host. Use the returned `index` "
81
+ "as the `camera_index` argument to capture_webcam_frame."
82
+ ),
83
+ )
84
+ async def _list_cameras() -> list[Any]:
85
+ result = await endpoint.list_cameras_safe()
86
+ if isinstance(result, ListCamerasError):
87
+ return [TextContent(type="text", text=result.message)]
88
+ assert isinstance(result, ListCamerasSuccess)
89
+ payload = [
90
+ {"index": c.index, "name": c.name, "available": c.available}
91
+ for c in result.cameras
92
+ ]
93
+ return [TextContent(type="text", text=json.dumps(payload))]
94
+
95
+
96
+ __all__ = ["register_webcam_tools"]
@@ -0,0 +1,107 @@
1
+ """Real-OpenCV CameraBackend.
2
+
3
+ Imports cv2 lazily inside method bodies so test environments that inject
4
+ a fake backend never need cv2 at module-import time. cv2's failure modes
5
+ are mapped onto our protocol exception taxonomy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from agent_core_webcam.protocol import (
11
+ CameraBackend,
12
+ CameraBusyError,
13
+ CameraInfo,
14
+ CameraNotFoundError,
15
+ WebcamError,
16
+ )
17
+
18
+ # Probe up to this many camera indices when listing. Most consumer hosts
19
+ # have 0–2 cameras; 8 is a generous ceiling without making list_cameras
20
+ # pay 100ms-each-failed-open across hundreds of indices.
21
+ _MAX_PROBE_INDEX = 8
22
+
23
+
24
+ class OpenCVCameraBackend:
25
+ """cv2-backed CameraBackend.
26
+
27
+ Open + release per call. No long-lived state — the OS hardware LED
28
+ reflects "agent is looking right now" honestly.
29
+ """
30
+
31
+ def __init__(self, *, timeout_seconds: float = 3.0):
32
+ self._timeout = timeout_seconds
33
+
34
+ def list_cameras(self) -> list[CameraInfo]:
35
+ import cv2 # lazy import so cv2 isn't required by importing this module
36
+
37
+ out: list[CameraInfo] = []
38
+ for idx in range(_MAX_PROBE_INDEX):
39
+ cap = cv2.VideoCapture(idx)
40
+ if cap is None:
41
+ continue
42
+ opened = cap.isOpened()
43
+ if not opened:
44
+ cap.release()
45
+ # No camera at this index — stop probing further indices.
46
+ # On Windows DirectShow, indices are dense from 0 upward.
47
+ if idx == 0:
48
+ return []
49
+ break
50
+ name = self._best_effort_name(cap, idx)
51
+ cap.release()
52
+ out.append(CameraInfo(index=idx, name=name, available=True))
53
+ return out
54
+
55
+ @staticmethod
56
+ def _best_effort_name(cap, idx: int) -> str:
57
+ # cv2 doesn't expose device names cross-platform. On Windows
58
+ # CAP_DSHOW we could probe via WMI but that's a rabbit hole.
59
+ # Fall back to a generic label.
60
+ return f"Camera {idx}"
61
+
62
+ def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
63
+ import cv2
64
+
65
+ cap = cv2.VideoCapture(index)
66
+ if cap is None or not cap.isOpened():
67
+ if cap is not None:
68
+ cap.release()
69
+ raise CameraNotFoundError(f"camera {index} could not be opened")
70
+ try:
71
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, resolution[0])
72
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1])
73
+ # Honor the timeout via cv2's own read; cv2 doesn't expose a
74
+ # blocking timeout per se, so we rely on cv2 returning False
75
+ # when no frame is available. A truly hung driver would
76
+ # require process-level handling — out of scope for v1.
77
+ ok, frame = cap.read()
78
+ if not ok or frame is None:
79
+ # On Windows, "device opened but read failed" most
80
+ # commonly means another process holds the video pin
81
+ # exclusively (Zoom, browser camera, etc.).
82
+ raise CameraBusyError(f"camera {index} opened but read failed")
83
+ # cv2.imencode expects a BGR array (cv2's in-memory convention) and
84
+ # writes RGB-ordered PNG bytes — i.e. it handles the channel swap
85
+ # internally. Do NOT pre-convert with cvtColor(BGR2RGB) — that
86
+ # produces a doubly-swapped PNG with R and B reversed.
87
+ #
88
+ # This was confirmed in two ways on 2026-05-06: (1) a synthetic test
89
+ # decoding raw IDAT bytes from a known-color test pattern, and
90
+ # (2) end-to-end Pepper captures shown to Jeff. The version WITHOUT
91
+ # cvtColor renders the room correctly (cool monitor light reflecting
92
+ # off neutral surfaces); the version WITH cvtColor adds an artificial
93
+ # yellow cast.
94
+ ok2, buf = cv2.imencode(".png", frame)
95
+ if not ok2:
96
+ raise WebcamError(f"camera {index} returned a frame but PNG encode failed")
97
+ return bytes(buf.tobytes())
98
+ finally:
99
+ cap.release()
100
+
101
+
102
+ # Static protocol satisfaction check (running the constructor is fine —
103
+ # cv2 is lazy-loaded inside method bodies).
104
+ _: CameraBackend = OpenCVCameraBackend()
105
+
106
+
107
+ __all__ = ["OpenCVCameraBackend"]
@@ -0,0 +1,87 @@
1
+ """Agent_core entry-point hook surface for the webcam framework.
2
+
3
+ Three hookimpls:
4
+
5
+ * ``register_endpoint_types`` — exposes ``builtin.webcam`` so the bus
6
+ runner can construct a ``WebcamEndpoint`` from a yaml entry.
7
+ * ``reserved_endpoint_params`` — declares ``webcam`` so the runner pops
8
+ it from claude_code_mcp's params before constructing.
9
+ * ``wire_endpoints_after_registration`` — pairs every
10
+ ``ClaudeCodeMCPEndpoint`` whose yaml params name a webcam endpoint
11
+ with that endpoint instance, appending a deferred mounter that
12
+ registers the two webcam tools on the FastMCP server at
13
+ ``bus.start()`` time.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import pluggy
21
+
22
+ if TYPE_CHECKING:
23
+ from agent_core.bus.protocol import Endpoint
24
+ from agent_core.plugins.specs import RunnerServices
25
+
26
+ hookimpl = pluggy.HookimplMarker("agent_core")
27
+
28
+
29
+ @hookimpl
30
+ def register_endpoint_types() -> dict[str, type[Any]]:
31
+ """Register ``builtin.webcam`` as a bus endpoint type."""
32
+ from agent_core_webcam.endpoint import WebcamEndpoint
33
+
34
+ return {"builtin.webcam": WebcamEndpoint}
35
+
36
+
37
+ @hookimpl
38
+ def reserved_endpoint_params() -> list[str]:
39
+ """The runner pops these keys from each endpoint's params before constructing."""
40
+ return ["webcam"]
41
+
42
+
43
+ @hookimpl
44
+ def wire_endpoints_after_registration(
45
+ endpoints: dict[str, Endpoint],
46
+ raw_endpoint_configs: dict[str, dict[str, Any]],
47
+ services: RunnerServices,
48
+ ) -> None:
49
+ """Mount webcam tools on every MCP endpoint that names a webcam endpoint."""
50
+ del services # unused — kept for hookspec parity
51
+ from agent_core.endpoints.claude_code_mcp import ClaudeCodeMCPEndpoint
52
+ from agent_core_webcam.endpoint import WebcamEndpoint
53
+ from agent_core_webcam.mcp import register_webcam_tools
54
+
55
+ for name, endpoint in endpoints.items():
56
+ if not isinstance(endpoint, ClaudeCodeMCPEndpoint):
57
+ continue
58
+ raw = raw_endpoint_configs.get(name) or {}
59
+ params = raw.get("params") or {}
60
+ webcam_name = params.get("webcam")
61
+ if not webcam_name:
62
+ continue
63
+ webcam = endpoints.get(webcam_name)
64
+ if webcam is None:
65
+ available = sorted(n for n, e in endpoints.items() if isinstance(e, WebcamEndpoint))
66
+ raise ValueError(
67
+ f"endpoint {name!r} names webcam={webcam_name!r} but no endpoint with "
68
+ f"that name is registered. Available WebcamEndpoint names: {available}"
69
+ )
70
+ if not isinstance(webcam, WebcamEndpoint):
71
+ available = sorted(n for n, e in endpoints.items() if isinstance(e, WebcamEndpoint))
72
+ raise ValueError(
73
+ f"endpoint {name!r} names webcam={webcam_name!r}, but that endpoint is "
74
+ f"a {type(webcam).__name__}, not a WebcamEndpoint. "
75
+ f"Available WebcamEndpoint names: {available}"
76
+ )
77
+
78
+ def _mounter(
79
+ bus_handle,
80
+ *,
81
+ webcam: WebcamEndpoint = webcam,
82
+ mcp_endpoint: ClaudeCodeMCPEndpoint = endpoint,
83
+ ) -> None:
84
+ del bus_handle # webcam tools don't publish onto the bus
85
+ register_webcam_tools(mcp=mcp_endpoint._mcp, endpoint=webcam)
86
+
87
+ endpoint.deferred_tool_mounters.append(_mounter)
@@ -0,0 +1,67 @@
1
+ """Camera backend protocol + exception taxonomy.
2
+
3
+ The ``CameraBackend`` Protocol is the seam that lets ``WebcamEndpoint``
4
+ work against either ``OpenCVCameraBackend`` (real) or ``FakeCameraBackend``
5
+ (deterministic for tests). All failure modes the endpoint maps to
6
+ agent-readable error messages descend from ``WebcamError`` so the
7
+ endpoint's exception handling stays simple.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Protocol, runtime_checkable
14
+
15
+
16
+ class WebcamError(Exception):
17
+ """Base for every error a CameraBackend may raise."""
18
+
19
+
20
+ class CameraNotFoundError(WebcamError):
21
+ """The requested camera index does not exist on this host."""
22
+
23
+
24
+ class CameraBusyError(WebcamError):
25
+ """The camera opened but is in use by another process."""
26
+
27
+
28
+ class ReadTimeoutError(WebcamError):
29
+ """The camera opened but did not return a frame within the timeout."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CameraInfo:
34
+ """One enumerated camera on the host."""
35
+
36
+ index: int
37
+ name: str
38
+ available: bool
39
+
40
+
41
+ @runtime_checkable
42
+ class CameraBackend(Protocol):
43
+ """Hardware-access seam used by WebcamEndpoint."""
44
+
45
+ def list_cameras(self) -> list[CameraInfo]:
46
+ """Enumerate cameras the host can see right now."""
47
+
48
+ def capture(self, index: int, resolution: tuple[int, int]) -> bytes:
49
+ """Capture one frame from camera ``index`` at ``resolution``.
50
+
51
+ Returns PNG-encoded bytes (sRGB, RGB-ordered).
52
+
53
+ Raises:
54
+ CameraNotFoundError: index not present on this host.
55
+ CameraBusyError: device opened but is in use elsewhere.
56
+ ReadTimeoutError: device opened but read() returned no frame.
57
+ """
58
+
59
+
60
+ __all__ = [
61
+ "CameraBackend",
62
+ "CameraBusyError",
63
+ "CameraInfo",
64
+ "CameraNotFoundError",
65
+ "ReadTimeoutError",
66
+ "WebcamError",
67
+ ]
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-core-webcam
3
+ Version: 0.8.2
4
+ Summary: Webcam endpoint for agent_core — agent-driven on-demand frame capture
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: agent-core-bus<0.9,>=0.8
8
+ Requires-Dist: fastmcp>=2.0
9
+ Requires-Dist: opencv-python>=5.0.0.93
10
+ Requires-Dist: pluggy>=1.6
11
+ Requires-Dist: pydantic>=2.7
@@ -0,0 +1,12 @@
1
+ agent_core_webcam/__init__.py,sha256=chVcO0DtqtnE6qEGbvU11I7vIffzEhCdrkCrYqVeQIk,68
2
+ agent_core_webcam/audit.py,sha256=i1fGHqWJNS1v_hE09O9MVzBlt51UICLEIb_cGUPyRJw,1372
3
+ agent_core_webcam/endpoint.py,sha256=wS-TcNLo065ge1s27SJpMrDjwoMoUlCADY2rA-za1TE,15441
4
+ agent_core_webcam/fake.py,sha256=gBRZfbbKvajaxBP2G_y2mfffPF0QMoiQoJj2NU-kMos,5224
5
+ agent_core_webcam/mcp.py,sha256=7LhAdXfVB-I91YOBjW-tlviqaoAPTJin0SuuSir9ku4,3343
6
+ agent_core_webcam/opencv_backend.py,sha256=FnKlZ9zAHs-fLrlYELAYfkaaspH6YkGCb7ar3QReEiI,4281
7
+ agent_core_webcam/plugin.py,sha256=SxRWfJ2HqTGKrTXxQnTYjdIvesgGvvARE_pOlTV6tm0,3297
8
+ agent_core_webcam/protocol.py,sha256=vbuIJ6pU2bpLrwx5866bgujKvgTOQNW2CSltuPcb4fU,1847
9
+ agent_core_webcam-0.8.2.dist-info/METADATA,sha256=YsH_DKsMhSXuxzDLN3F7nnvC_PJWACzhnIY4zRHqgjU,353
10
+ agent_core_webcam-0.8.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ agent_core_webcam-0.8.2.dist-info/entry_points.txt,sha256=K8iIBlcP5gMp7FX_yCVeD5jTrQzS9E61SMK5924cpE4,55
12
+ agent_core_webcam-0.8.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [agent_core]
2
+ webcam_aliases = agent_core_webcam.plugin