dooers-workers 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,97 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+ from typing import TYPE_CHECKING, Any
6
+ from uuid import uuid4
7
+
8
+ if TYPE_CHECKING:
9
+ from ..registry import ConnectionRegistry, WebSocketProtocol
10
+ from .models import SettingsSchema
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SettingsBroadcaster:
16
+ """
17
+ Broadcasts settings changes to subscribed WebSocket connections.
18
+
19
+ Supports:
20
+ - Snapshot delivery on subscription
21
+ - Patch broadcast when settings are updated
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ registry: ConnectionRegistry,
27
+ subscriptions: dict[str, set[str]], # worker_id -> set of ws_ids
28
+ ) -> None:
29
+ self._registry = registry
30
+ self._subscriptions = subscriptions
31
+
32
+ async def broadcast_snapshot_to_ws(
33
+ self,
34
+ worker_id: str,
35
+ ws: WebSocketProtocol,
36
+ schema: SettingsSchema,
37
+ values: dict[str, Any],
38
+ ) -> None:
39
+ """Send a settings snapshot directly to a specific WebSocket connection."""
40
+ from ..protocol.frames import S2C_SettingsSnapshot, SettingsSnapshotPayload
41
+ from .models import SettingsField
42
+
43
+ # Merge defaults with stored values
44
+ fields_with_values: list[SettingsField] = []
45
+ for field in schema.fields:
46
+ field_copy = field.model_copy()
47
+ if field.id in values:
48
+ field_copy.value = values[field.id]
49
+ fields_with_values.append(field_copy)
50
+
51
+ message = S2C_SettingsSnapshot(
52
+ id=str(uuid4()),
53
+ payload=SettingsSnapshotPayload(
54
+ worker_id=worker_id,
55
+ fields=fields_with_values,
56
+ updated_at=datetime.now(UTC),
57
+ ),
58
+ )
59
+
60
+ try:
61
+ await ws.send_text(message.model_dump_json())
62
+ except Exception:
63
+ logger.warning("Failed to send settings snapshot")
64
+
65
+ async def broadcast_patch(
66
+ self,
67
+ worker_id: str,
68
+ field_id: str,
69
+ value: Any,
70
+ exclude_ws: WebSocketProtocol | None = None,
71
+ ) -> None:
72
+ """Broadcast a settings patch to all subscribers for a worker."""
73
+ from ..protocol.frames import S2C_SettingsPatch, SettingsPatchBroadcastPayload
74
+
75
+ subscriber_ws_ids = self._subscriptions.get(worker_id, set())
76
+ if not subscriber_ws_ids:
77
+ return
78
+
79
+ message = S2C_SettingsPatch(
80
+ id=str(uuid4()),
81
+ payload=SettingsPatchBroadcastPayload(
82
+ worker_id=worker_id,
83
+ field_id=field_id,
84
+ value=value,
85
+ updated_at=datetime.now(UTC),
86
+ ),
87
+ )
88
+ message_json = message.model_dump_json()
89
+
90
+ # Send to all connections for the worker except the one that made the change
91
+ connections = self._registry.get_connections(worker_id)
92
+ for ws in connections:
93
+ if ws is not exclude_ws:
94
+ try:
95
+ await ws.send_text(message_json)
96
+ except Exception:
97
+ logger.warning("Failed to send settings patch to subscriber")
@@ -0,0 +1,72 @@
1
+ from enum import StrEnum
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, model_validator
5
+
6
+
7
+ class SettingsFieldType(StrEnum):
8
+ """Supported field types for worker settings."""
9
+
10
+ TEXT = "text"
11
+ NUMBER = "number"
12
+ SELECT = "select"
13
+ CHECKBOX = "checkbox"
14
+ TEXTAREA = "textarea"
15
+ PASSWORD = "password"
16
+ EMAIL = "email"
17
+ DATE = "date"
18
+ IMAGE = "image" # Display-only (QR codes, etc.)
19
+
20
+
21
+ class SettingsSelectOption(BaseModel):
22
+ """Option for select fields."""
23
+
24
+ value: str
25
+ label: str
26
+
27
+
28
+ class SettingsField(BaseModel):
29
+ """Definition of a single settings field."""
30
+
31
+ id: str
32
+ type: SettingsFieldType
33
+ label: str
34
+ required: bool = False
35
+ readonly: bool = False
36
+ value: Any = None # Default value
37
+
38
+ # Type-specific options
39
+ placeholder: str | None = None
40
+ options: list[SettingsSelectOption] | None = None # For select
41
+ min: int | float | None = None # For number
42
+ max: int | float | None = None # For number
43
+ rows: int | None = None # For textarea
44
+ src: str | None = None # For image (display URL)
45
+ width: int | None = None # For image
46
+ height: int | None = None # For image
47
+
48
+
49
+ class SettingsSchema(BaseModel):
50
+ """Schema definition for worker settings."""
51
+
52
+ version: str = "1.0"
53
+ fields: list[SettingsField]
54
+
55
+ @model_validator(mode="after")
56
+ def validate_unique_ids(self) -> "SettingsSchema":
57
+ """Ensure all field IDs are unique."""
58
+ ids = [f.id for f in self.fields]
59
+ if len(ids) != len(set(ids)):
60
+ raise ValueError("Field IDs must be unique")
61
+ return self
62
+
63
+ def get_field(self, field_id: str) -> SettingsField | None:
64
+ """Get a field by its ID."""
65
+ for field in self.fields:
66
+ if field.id == field_id:
67
+ return field
68
+ return None
69
+
70
+ def get_defaults(self) -> dict[str, Any]:
71
+ """Get default values for all fields."""
72
+ return {f.id: f.value for f in self.fields}
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from ..persistence.base import Persistence
7
+ from .broadcaster import SettingsBroadcaster
8
+ from .models import SettingsSchema
9
+
10
+
11
+ class WorkerSettings:
12
+ """
13
+ Handler API for dynamic settings.
14
+
15
+ Provides methods for getting and setting worker-level configuration.
16
+ Passed as the fifth parameter to handler functions.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ worker_id: str,
22
+ schema: SettingsSchema,
23
+ persistence: Persistence,
24
+ broadcaster: SettingsBroadcaster,
25
+ ) -> None:
26
+ self._worker_id = worker_id
27
+ self._schema = schema
28
+ self._persistence = persistence
29
+ self._broadcaster = broadcaster
30
+
31
+ async def get(self, field_id: str) -> Any:
32
+ """
33
+ Get a single field value.
34
+
35
+ Args:
36
+ field_id: The field ID to get
37
+
38
+ Returns:
39
+ The field value (stored or default)
40
+
41
+ Raises:
42
+ KeyError: If field_id doesn't exist in schema
43
+ """
44
+ field = self._schema.get_field(field_id)
45
+ if not field:
46
+ raise KeyError(f"Unknown field: {field_id}")
47
+
48
+ values = await self._persistence.get_settings(self._worker_id)
49
+ return values.get(field_id, field.value)
50
+
51
+ async def get_all(self) -> dict[str, Any]:
52
+ """
53
+ Get all field values as a dict.
54
+
55
+ Returns:
56
+ Dict mapping field_id to value (stored values merged with defaults)
57
+ """
58
+ defaults = self._schema.get_defaults()
59
+ stored = await self._persistence.get_settings(self._worker_id)
60
+ return {**defaults, **stored}
61
+
62
+ async def set(self, field_id: str, value: Any) -> None:
63
+ """
64
+ Update a field value and broadcast to subscribers.
65
+
66
+ Args:
67
+ field_id: The field ID to update
68
+ value: The new value
69
+
70
+ Raises:
71
+ KeyError: If field_id doesn't exist in schema
72
+ ValueError: If field is readonly
73
+ """
74
+ self._validate_field(field_id, value)
75
+ await self._persistence.update_setting(self._worker_id, field_id, value)
76
+ await self._broadcaster.broadcast_patch(self._worker_id, field_id, value)
77
+
78
+ def _validate_field(self, field_id: str, value: Any) -> None:
79
+ """Validate that a field exists and can be set."""
80
+ field = self._schema.get_field(field_id)
81
+ if not field:
82
+ raise KeyError(f"Unknown field: {field_id}")
83
+ if field.readonly:
84
+ raise ValueError(f"Field '{field_id}' is readonly")
85
+ # Additional type validation can be added here in the future
@@ -0,0 +1,16 @@
1
+ from dooers.features.analytics.worker_analytics import WorkerAnalytics
2
+ from dooers.features.settings.worker_settings import WorkerSettings
3
+ from dooers.handlers.memory import WorkerMemory
4
+ from dooers.handlers.request import WorkerRequest
5
+ from dooers.handlers.response import WorkerEvent, WorkerResponse
6
+ from dooers.handlers.router import Router
7
+
8
+ __all__ = [
9
+ "WorkerResponse",
10
+ "WorkerEvent",
11
+ "WorkerRequest",
12
+ "WorkerMemory",
13
+ "WorkerAnalytics",
14
+ "WorkerSettings",
15
+ "Router",
16
+ ]
@@ -0,0 +1,105 @@
1
+ from typing import TYPE_CHECKING, Any, Literal
2
+
3
+ from dooers.protocol.models import ThreadEvent
4
+
5
+ if TYPE_CHECKING:
6
+ from dooers.persistence.base import Persistence
7
+
8
+ HistoryFormat = Literal["openai", "anthropic", "google", "cohere", "voyage"]
9
+
10
+
11
+ class WorkerMemory:
12
+ def __init__(self, thread_id: str, persistence: "Persistence"):
13
+ self._thread_id = thread_id
14
+ self._persistence = persistence
15
+
16
+ async def get_history_raw(self, limit: int = 50) -> list[ThreadEvent]:
17
+ """
18
+ Get raw conversation history as ThreadEvent objects.
19
+
20
+ Args:
21
+ limit: Maximum number of events to return
22
+
23
+ Returns:
24
+ List of ThreadEvent objects
25
+ """
26
+ return await self._persistence.get_events(
27
+ thread_id=self._thread_id,
28
+ after_event_id=None,
29
+ limit=limit,
30
+ )
31
+
32
+ async def get_history(
33
+ self,
34
+ limit: int = 50,
35
+ format: HistoryFormat = "openai",
36
+ ) -> list[dict[str, Any]]:
37
+ """
38
+ Get conversation history formatted for LLM APIs.
39
+
40
+ Args:
41
+ limit: Maximum number of events to return
42
+ format: Output format - 'openai', 'anthropic', 'google', 'cohere', 'voyage'
43
+
44
+ Returns:
45
+ List of message dicts in the specified format
46
+
47
+ Format examples:
48
+ openai: {"role": "user/assistant", "content": "..."}
49
+ anthropic: {"role": "user/assistant", "content": "..."}
50
+ google: {"role": "user/model", "parts": [{"text": "..."}]}
51
+ cohere: {"role": "USER/CHATBOT", "message": "..."}
52
+ voyage: {"role": "user/assistant", "content": "..."}
53
+ """
54
+ events = await self.get_history_raw(limit)
55
+ messages: list[dict[str, Any]] = []
56
+
57
+ for event in events:
58
+ if event.type != "message" or not event.content:
59
+ continue
60
+
61
+ # Extract text from content parts
62
+ text = " ".join(part.text for part in event.content if hasattr(part, "text"))
63
+ if not text:
64
+ continue
65
+
66
+ is_user = event.actor == "user"
67
+ message = self._format_message(text, is_user, format)
68
+ messages.append(message)
69
+
70
+ return messages
71
+
72
+ def _format_message(
73
+ self,
74
+ text: str,
75
+ is_user: bool,
76
+ format: HistoryFormat,
77
+ ) -> dict[str, Any]:
78
+ """Format a single message for the specified provider."""
79
+ match format:
80
+ case "openai" | "voyage":
81
+ return {
82
+ "role": "user" if is_user else "assistant",
83
+ "content": text,
84
+ }
85
+ case "anthropic":
86
+ return {
87
+ "role": "user" if is_user else "assistant",
88
+ "content": text,
89
+ }
90
+ case "google":
91
+ return {
92
+ "role": "user" if is_user else "model",
93
+ "parts": [{"text": text}],
94
+ }
95
+ case "cohere":
96
+ return {
97
+ "role": "USER" if is_user else "CHATBOT",
98
+ "message": text,
99
+ }
100
+ case _:
101
+ # Default to OpenAI format
102
+ return {
103
+ "role": "user" if is_user else "assistant",
104
+ "content": text,
105
+ }
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+ from dooers.protocol.models import ContentPart
4
+
5
+
6
+ @dataclass
7
+ class WorkerRequest:
8
+ message: str
9
+ content: list[ContentPart]
10
+ thread_id: str
11
+ event_id: str
12
+ user_id: str | None
@@ -0,0 +1,66 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass
6
+ class WorkerEvent:
7
+ response_type: str
8
+ data: dict
9
+
10
+
11
+ class WorkerResponse:
12
+ def text(self, text: str) -> WorkerEvent:
13
+ return WorkerEvent(
14
+ response_type="text",
15
+ data={"text": text},
16
+ )
17
+
18
+ def image(
19
+ self,
20
+ url: str,
21
+ mime_type: str | None = None,
22
+ alt: str | None = None,
23
+ ) -> WorkerEvent:
24
+ return WorkerEvent(
25
+ response_type="image",
26
+ data={"url": url, "mime_type": mime_type, "alt": alt},
27
+ )
28
+
29
+ def document(
30
+ self,
31
+ url: str,
32
+ filename: str,
33
+ mime_type: str,
34
+ ) -> WorkerEvent:
35
+ return WorkerEvent(
36
+ response_type="document",
37
+ data={"url": url, "filename": filename, "mime_type": mime_type},
38
+ )
39
+
40
+ def tool_call(self, name: str, args: dict) -> WorkerEvent:
41
+ return WorkerEvent(
42
+ response_type="tool_call",
43
+ data={"name": name, "args": args},
44
+ )
45
+
46
+ def tool_result(self, name: str, result: dict) -> WorkerEvent:
47
+ return WorkerEvent(
48
+ response_type="tool_result",
49
+ data={"name": name, "result": result},
50
+ )
51
+
52
+ def run_start(self, agent_id: str | None = None) -> WorkerEvent:
53
+ return WorkerEvent(
54
+ response_type="run_start",
55
+ data={"agent_id": agent_id},
56
+ )
57
+
58
+ def run_end(
59
+ self,
60
+ status: Literal["succeeded", "failed"] = "succeeded",
61
+ error: str | None = None,
62
+ ) -> WorkerEvent:
63
+ return WorkerEvent(
64
+ response_type="run_end",
65
+ data={"status": status, "error": error},
66
+ )