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.
- dooers/__init__.py +73 -0
- dooers/broadcast.py +180 -0
- dooers/config.py +22 -0
- dooers/features/__init__.py +0 -0
- dooers/features/analytics/__init__.py +12 -0
- dooers/features/analytics/collector.py +219 -0
- dooers/features/analytics/models.py +50 -0
- dooers/features/analytics/worker_analytics.py +100 -0
- dooers/features/settings/__init__.py +12 -0
- dooers/features/settings/broadcaster.py +97 -0
- dooers/features/settings/models.py +72 -0
- dooers/features/settings/worker_settings.py +85 -0
- dooers/handlers/__init__.py +16 -0
- dooers/handlers/memory.py +105 -0
- dooers/handlers/request.py +12 -0
- dooers/handlers/response.py +66 -0
- dooers/handlers/router.py +957 -0
- dooers/migrations/__init__.py +3 -0
- dooers/migrations/schemas.py +126 -0
- dooers/persistence/__init__.py +9 -0
- dooers/persistence/base.py +42 -0
- dooers/persistence/postgres.py +459 -0
- dooers/persistence/sqlite.py +433 -0
- dooers/protocol/__init__.py +108 -0
- dooers/protocol/frames.py +298 -0
- dooers/protocol/models.py +72 -0
- dooers/protocol/parser.py +19 -0
- dooers/registry.py +101 -0
- dooers/server.py +162 -0
- dooers/settings.py +3 -0
- dooers_workers-0.2.0.dist-info/METADATA +228 -0
- dooers_workers-0.2.0.dist-info/RECORD +33 -0
- dooers_workers-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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,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
|
+
)
|