smithy-engine 0.6.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.
- smithy/__init__.py +75 -0
- smithy/core/__init__.py +28 -0
- smithy/core/blocking.py +53 -0
- smithy/core/config.py +171 -0
- smithy/core/errors.py +105 -0
- smithy/core/events.py +67 -0
- smithy/core/http_queue.py +298 -0
- smithy/core/logging.py +73 -0
- smithy/core/queue.py +503 -0
- smithy/core/registry.py +66 -0
- smithy/core/retry.py +79 -0
- smithy/core/schema.py +92 -0
- smithy/core/tool.py +114 -0
- smithy/core/transactions.py +523 -0
- smithy/facade.py +652 -0
- smithy/py.typed +0 -0
- smithy/windows/__init__.py +0 -0
- smithy/windows/element.py +84 -0
- smithy/windows/selector.py +320 -0
- smithy/windows/selector_rank.py +280 -0
- smithy/windows/tools/__init__.py +72 -0
- smithy/windows/tools/_resolve.py +139 -0
- smithy/windows/tools/click.py +147 -0
- smithy/windows/tools/clipboard.py +82 -0
- smithy/windows/tools/delay.py +55 -0
- smithy/windows/tools/drag.py +82 -0
- smithy/windows/tools/exists.py +59 -0
- smithy/windows/tools/get_element.py +59 -0
- smithy/windows/tools/get_text.py +76 -0
- smithy/windows/tools/highlight.py +122 -0
- smithy/windows/tools/hover.py +62 -0
- smithy/windows/tools/input_text.py +77 -0
- smithy/windows/tools/keyboard.py +270 -0
- smithy/windows/tools/list_elements.py +92 -0
- smithy/windows/tools/process.py +239 -0
- smithy/windows/tools/screenshot.py +201 -0
- smithy/windows/tools/scroll.py +100 -0
- smithy/windows/tools/select.py +67 -0
- smithy/windows/tools/selector_capture/__init__.py +28 -0
- smithy/windows/tools/selector_capture/__main__.py +5 -0
- smithy/windows/tools/selector_capture/capture.py +387 -0
- smithy/windows/tools/selector_capture/cli.py +149 -0
- smithy/windows/tools/selector_capture/emit.py +151 -0
- smithy/windows/tools/selector_capture/generate.py +274 -0
- smithy/windows/tools/selector_capture/recorder.py +739 -0
- smithy/windows/tools/set_text.py +110 -0
- smithy/windows/tools/wait.py +150 -0
- smithy/windows/tools/window.py +147 -0
- smithy_engine-0.6.0.dist-info/METADATA +372 -0
- smithy_engine-0.6.0.dist-info/RECORD +51 -0
- smithy_engine-0.6.0.dist-info/WHEEL +4 -0
smithy/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Smithy — Free Python RPA engine for creating automation bots."""
|
|
2
|
+
|
|
3
|
+
from smithy.core.config import Config, load_config
|
|
4
|
+
from smithy.core.errors import (
|
|
5
|
+
BusinessError,
|
|
6
|
+
Cancelled,
|
|
7
|
+
ConfigError,
|
|
8
|
+
ElementNotFound,
|
|
9
|
+
InfrastructureError,
|
|
10
|
+
InvalidInput,
|
|
11
|
+
PlatformError,
|
|
12
|
+
ToolError,
|
|
13
|
+
)
|
|
14
|
+
from smithy.core.http_queue import HttpQueue, HttpQueueError
|
|
15
|
+
from smithy.core.logging import JsonlEventLogger
|
|
16
|
+
from smithy.core.queue import (
|
|
17
|
+
ClaimedItem,
|
|
18
|
+
InMemoryQueue,
|
|
19
|
+
LeaseRenewable,
|
|
20
|
+
Queue,
|
|
21
|
+
QueueInfo,
|
|
22
|
+
QueueItem,
|
|
23
|
+
SqliteQueue,
|
|
24
|
+
)
|
|
25
|
+
from smithy.core.retry import RetryTool
|
|
26
|
+
from smithy.core.schema import validate_against_schema
|
|
27
|
+
from smithy.core.tool import AbstractTool, Tool, tool
|
|
28
|
+
from smithy.core.transactions import (
|
|
29
|
+
ItemOutcome,
|
|
30
|
+
TransactionContextMiddleware,
|
|
31
|
+
TransactionReport,
|
|
32
|
+
current_transaction_id,
|
|
33
|
+
run_transactions,
|
|
34
|
+
run_transactions_async,
|
|
35
|
+
)
|
|
36
|
+
from smithy.facade import ClickResult, ProcessHandle, Smithy
|
|
37
|
+
|
|
38
|
+
__version__ = "0.6.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AbstractTool",
|
|
42
|
+
"BusinessError",
|
|
43
|
+
"Cancelled",
|
|
44
|
+
"ClaimedItem",
|
|
45
|
+
"ClickResult",
|
|
46
|
+
"Config",
|
|
47
|
+
"ConfigError",
|
|
48
|
+
"ElementNotFound",
|
|
49
|
+
"HttpQueue",
|
|
50
|
+
"HttpQueueError",
|
|
51
|
+
"InMemoryQueue",
|
|
52
|
+
"InvalidInput",
|
|
53
|
+
"ItemOutcome",
|
|
54
|
+
"JsonlEventLogger",
|
|
55
|
+
"LeaseRenewable",
|
|
56
|
+
"PlatformError",
|
|
57
|
+
"ProcessHandle",
|
|
58
|
+
"Queue",
|
|
59
|
+
"QueueInfo",
|
|
60
|
+
"QueueItem",
|
|
61
|
+
"RetryTool",
|
|
62
|
+
"Smithy",
|
|
63
|
+
"SqliteQueue",
|
|
64
|
+
"InfrastructureError",
|
|
65
|
+
"Tool",
|
|
66
|
+
"ToolError",
|
|
67
|
+
"TransactionContextMiddleware",
|
|
68
|
+
"TransactionReport",
|
|
69
|
+
"current_transaction_id",
|
|
70
|
+
"load_config",
|
|
71
|
+
"run_transactions",
|
|
72
|
+
"run_transactions_async",
|
|
73
|
+
"tool",
|
|
74
|
+
"validate_against_schema",
|
|
75
|
+
]
|
smithy/core/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Core traits, types, and error definitions."""
|
|
2
|
+
|
|
3
|
+
from smithy.core.errors import (
|
|
4
|
+
BusinessError,
|
|
5
|
+
Cancelled,
|
|
6
|
+
ConfigError,
|
|
7
|
+
ElementNotFound,
|
|
8
|
+
InfrastructureError,
|
|
9
|
+
InvalidInput,
|
|
10
|
+
PlatformError,
|
|
11
|
+
ToolError,
|
|
12
|
+
)
|
|
13
|
+
from smithy.core.registry import ToolRegistry
|
|
14
|
+
from smithy.core.tool import AbstractTool, Tool
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AbstractTool",
|
|
18
|
+
"BusinessError",
|
|
19
|
+
"Cancelled",
|
|
20
|
+
"ConfigError",
|
|
21
|
+
"ElementNotFound",
|
|
22
|
+
"InfrastructureError",
|
|
23
|
+
"InvalidInput",
|
|
24
|
+
"PlatformError",
|
|
25
|
+
"Tool",
|
|
26
|
+
"ToolError",
|
|
27
|
+
"ToolRegistry",
|
|
28
|
+
]
|
smithy/core/blocking.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Offload blocking calls to a worker thread with a hard timeout.
|
|
2
|
+
|
|
3
|
+
UIA/COM calls can hang indefinitely (dead dialogs, stalled COM apartments).
|
|
4
|
+
Every blocking offload in the windows tools goes through :func:`run_blocking`,
|
|
5
|
+
which bounds the wait: default 30 s, tunable via the ``SMITHY_BLOCKING_TIMEOUT``
|
|
6
|
+
environment variable. A timeout raises :class:`PlatformError` instead of
|
|
7
|
+
leaving the bot blocked forever on a starved thread pool.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import functools
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from smithy.core.errors import PlatformError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _default_timeout() -> float:
|
|
22
|
+
raw = os.environ.get("SMITHY_BLOCKING_TIMEOUT", "30")
|
|
23
|
+
try:
|
|
24
|
+
value = float(raw)
|
|
25
|
+
except ValueError:
|
|
26
|
+
return 30.0
|
|
27
|
+
return value if value > 0 else 30.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def run_blocking(
|
|
31
|
+
fn: Callable[..., Any],
|
|
32
|
+
/,
|
|
33
|
+
*args: Any,
|
|
34
|
+
timeout: float | None = None,
|
|
35
|
+
**kwargs: Any,
|
|
36
|
+
) -> Any:
|
|
37
|
+
"""Run ``fn(*args, **kwargs)`` in the default executor with a timeout.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
PlatformError: If the call does not finish within *timeout* seconds
|
|
41
|
+
(default: ``SMITHY_BLOCKING_TIMEOUT`` env var, else 30 s).
|
|
42
|
+
"""
|
|
43
|
+
limit = timeout if timeout is not None else _default_timeout()
|
|
44
|
+
loop = asyncio.get_running_loop()
|
|
45
|
+
future = loop.run_in_executor(None, functools.partial(fn, *args, **kwargs))
|
|
46
|
+
try:
|
|
47
|
+
return await asyncio.wait_for(future, limit)
|
|
48
|
+
except TimeoutError as exc:
|
|
49
|
+
name = getattr(fn, "__qualname__", None) or repr(fn)
|
|
50
|
+
raise PlatformError(
|
|
51
|
+
f"blocking call {name} timed out after {limit:g}s "
|
|
52
|
+
"(tune SMITHY_BLOCKING_TIMEOUT if this is expected)",
|
|
53
|
+
) from exc
|
smithy/core/config.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""TOML robot config — load once in Init, fail fast, frozen afterwards.
|
|
2
|
+
|
|
3
|
+
The file replaces the legacy two-column Excel sheet: same idea (one config
|
|
4
|
+
per robot, values differ per environment), but diffable in git, typed, and
|
|
5
|
+
validated up front. Secrets never live here — only *references* to
|
|
6
|
+
orchestrator assets (names, GUIDs); values are fetched at runtime.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from smithy.core.errors import ConfigError
|
|
17
|
+
|
|
18
|
+
_MISSING = object()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Config:
|
|
22
|
+
"""Immutable attribute-style view over a loaded TOML document.
|
|
23
|
+
|
|
24
|
+
Nested tables become nested :class:`Config`, lists become tuples —
|
|
25
|
+
nothing inside can be reassigned after loading. Access by attribute
|
|
26
|
+
(``config.paths.workdir``) or by item (``config[\"paths\"][\"workdir\"]``).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
__slots__ = ("_data",)
|
|
30
|
+
|
|
31
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
32
|
+
object.__setattr__(self, "_data", data)
|
|
33
|
+
|
|
34
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
35
|
+
raise AttributeError(f"Config is frozen; cannot set {name!r}")
|
|
36
|
+
|
|
37
|
+
def __getattr__(self, name: str) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
return self._data[name]
|
|
40
|
+
except KeyError:
|
|
41
|
+
raise AttributeError(f"Unknown config key: {name!r}") from None
|
|
42
|
+
|
|
43
|
+
def __getitem__(self, key: str) -> Any:
|
|
44
|
+
return self._data[key]
|
|
45
|
+
|
|
46
|
+
def __contains__(self, key: object) -> bool:
|
|
47
|
+
return key in self._data
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return f"Config({self._data!r})"
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
"""Plain deep copy of the document (logging, debugging)."""
|
|
54
|
+
return {key: _thaw(value) for key, value in self._data.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _freeze(value: Any) -> Any:
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
return Config({key: _freeze(item) for key, item in value.items()})
|
|
60
|
+
if isinstance(value, list):
|
|
61
|
+
return tuple(_freeze(item) for item in value)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _thaw(value: Any) -> Any:
|
|
66
|
+
if isinstance(value, Config):
|
|
67
|
+
return value.to_dict()
|
|
68
|
+
if isinstance(value, tuple):
|
|
69
|
+
return [_thaw(item) for item in value]
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _lookup(config: Config, dotted: str) -> Any:
|
|
74
|
+
current: Any = config
|
|
75
|
+
for part in dotted.split("."):
|
|
76
|
+
if isinstance(current, Config) and part in current:
|
|
77
|
+
current = current[part]
|
|
78
|
+
else:
|
|
79
|
+
return _MISSING
|
|
80
|
+
return current
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_env_value(raw: str) -> Any:
|
|
84
|
+
"""Interpret an env value with TOML scalar syntax; fall back to string.
|
|
85
|
+
|
|
86
|
+
``"8080"`` becomes ``8080``, ``"true"`` becomes ``True``,
|
|
87
|
+
``"C:\\temp"`` (not valid TOML) stays a plain string.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return tomllib.loads(f"value = {raw}")["value"]
|
|
91
|
+
except ValueError:
|
|
92
|
+
return raw
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _deep_set(document: dict[str, Any], parts: list[str], value: Any) -> None:
|
|
96
|
+
"""Set a nested key, creating intermediate tables as needed."""
|
|
97
|
+
current = document
|
|
98
|
+
for part in parts[:-1]:
|
|
99
|
+
child = current.get(part)
|
|
100
|
+
if not isinstance(child, dict):
|
|
101
|
+
child = {}
|
|
102
|
+
current[part] = child
|
|
103
|
+
current = child
|
|
104
|
+
current[parts[-1]] = value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_env_overlay(document: dict[str, Any], prefix: str) -> None:
|
|
108
|
+
"""Overlay ``<prefix>*`` env vars onto the TOML document (in place).
|
|
109
|
+
|
|
110
|
+
``SMITHY_ROBOT__QUEUE`` sets ``robot.queue``; double underscore nests,
|
|
111
|
+
single underscores stay literal (``SMITHY_ROBOT_NAME`` → ``robot_name``).
|
|
112
|
+
Values are typed with TOML scalar syntax (ints, bools, quoted strings).
|
|
113
|
+
"""
|
|
114
|
+
for name, raw in os.environ.items():
|
|
115
|
+
if not name.startswith(prefix):
|
|
116
|
+
continue
|
|
117
|
+
rest = name[len(prefix) :].lower()
|
|
118
|
+
if not rest:
|
|
119
|
+
continue
|
|
120
|
+
_deep_set(document, rest.split("__"), _parse_env_value(raw))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_config(
|
|
124
|
+
path: str | Path,
|
|
125
|
+
*,
|
|
126
|
+
required: tuple[str, ...] | list[str] = (),
|
|
127
|
+
must_exist: tuple[str, ...] | list[str] = (),
|
|
128
|
+
env_prefix: str | None = "SMITHY_",
|
|
129
|
+
) -> Config:
|
|
130
|
+
"""Load *path* as TOML and validate it.
|
|
131
|
+
|
|
132
|
+
Raises one :class:`ConfigError` listing *every* problem at once —
|
|
133
|
+
the robot fails in Init, never mid-run. *required* are dotted keys
|
|
134
|
+
that must be present (``\"robot.queue\"``); *must_exist* are dotted
|
|
135
|
+
keys whose values must be existing filesystem paths.
|
|
136
|
+
|
|
137
|
+
When *env_prefix* is set (default ``\"SMITHY_\"``), matching env vars
|
|
138
|
+
override file values — per-environment tweaks without editing TOML.
|
|
139
|
+
``SMITHY_ROBOT__QUEUE`` sets ``robot.queue`` (``__`` nests, values are
|
|
140
|
+
TOML-typed). Checks run *after* the overlay, so env can satisfy
|
|
141
|
+
*required*. Pass ``env_prefix=None`` to disable.
|
|
142
|
+
"""
|
|
143
|
+
file = Path(path)
|
|
144
|
+
try:
|
|
145
|
+
raw = file.read_bytes()
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
raise ConfigError(f"Cannot read config file: {file}", input_value=str(file)) from exc
|
|
148
|
+
try:
|
|
149
|
+
document = tomllib.loads(raw.decode("utf-8"))
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise ConfigError(f"Invalid TOML in {file}: {exc}", input_value=str(file)) from exc
|
|
152
|
+
if env_prefix:
|
|
153
|
+
_apply_env_overlay(document, env_prefix)
|
|
154
|
+
frozen = _freeze(document)
|
|
155
|
+
assert isinstance(frozen, Config)
|
|
156
|
+
problems: list[str] = []
|
|
157
|
+
for key in required:
|
|
158
|
+
if _lookup(frozen, key) is _MISSING:
|
|
159
|
+
problems.append(f"missing required key: {key!r}")
|
|
160
|
+
for key in must_exist:
|
|
161
|
+
value = _lookup(frozen, key)
|
|
162
|
+
if value is _MISSING:
|
|
163
|
+
problems.append(f"missing path key: {key!r}")
|
|
164
|
+
elif not isinstance(value, (str, Path)) or not Path(value).exists():
|
|
165
|
+
problems.append(f"path does not exist: {key!r} = {value!r}")
|
|
166
|
+
if problems:
|
|
167
|
+
raise ConfigError(
|
|
168
|
+
f"Invalid config {file}:\n" + "\n".join(f" - {item}" for item in problems),
|
|
169
|
+
input_value=str(file),
|
|
170
|
+
)
|
|
171
|
+
return frozen
|
smithy/core/errors.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Error types for tool execution and the agent framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolError(Exception):
|
|
9
|
+
"""Structured error for tool execution.
|
|
10
|
+
|
|
11
|
+
Subclasses:
|
|
12
|
+
InvalidInput — invalid or missing input parameters.
|
|
13
|
+
ElementNotFound — UI element not found or inaccessible.
|
|
14
|
+
Cancelled — operation cancelled by user or authority.
|
|
15
|
+
PlatformError — platform or UIA error with underlying cause.
|
|
16
|
+
BusinessError — transaction data is invalid, retry is pointless.
|
|
17
|
+
InfrastructureError — infrastructure failure, retry may help.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidInput(ToolError):
|
|
25
|
+
"""Invalid or missing input parameters."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
param: str | None = None,
|
|
32
|
+
input_value: Any = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.param = param
|
|
36
|
+
self.input_value = input_value
|
|
37
|
+
|
|
38
|
+
def __repr__(self) -> str:
|
|
39
|
+
return (
|
|
40
|
+
f"{type(self).__name__}({str(self)!r}, "
|
|
41
|
+
f"param={self.param!r}, input_value={self.input_value!r})"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ElementNotFound(ToolError):
|
|
46
|
+
"""UI element not found or inaccessible."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str = "Element not found",
|
|
51
|
+
*,
|
|
52
|
+
selector: Any = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.selector = selector
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"{type(self).__name__}({str(self)!r}, selector={self.selector!r})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Cancelled(ToolError):
|
|
62
|
+
"""Operation cancelled by user or authority."""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
super().__init__("Operation cancelled")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PlatformError(ToolError):
|
|
69
|
+
"""Platform or UIA error with underlying cause."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
message: str,
|
|
74
|
+
*,
|
|
75
|
+
source: BaseException | None = None,
|
|
76
|
+
input_value: Any = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
self.source = source
|
|
80
|
+
self.input_value = input_value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BusinessError(ToolError):
|
|
84
|
+
"""Transaction data is invalid — retrying the same payload is pointless.
|
|
85
|
+
|
|
86
|
+
Raised by ``process_fn`` inside the transaction runner to mark the
|
|
87
|
+
item as ``business_failed`` (terminal, no requeue).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class InfrastructureError(ToolError):
|
|
92
|
+
"""Infrastructure failure — retrying may help.
|
|
93
|
+
|
|
94
|
+
Raised by ``process_fn`` (or produced by the runner from unexpected
|
|
95
|
+
exceptions) to mark the item as ``system_failed`` (requeued until
|
|
96
|
+
the queue's ``max_attempts`` budget is exhausted).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ConfigError(InvalidInput):
|
|
101
|
+
"""Robot config is missing, unreadable, or fails validation.
|
|
102
|
+
|
|
103
|
+
Raised once with every problem listed — the robot must not start
|
|
104
|
+
with a half-valid config.
|
|
105
|
+
"""
|
smithy/core/events.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Middleware event system for tool observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ToolEvent:
|
|
12
|
+
"""Event emitted after each tool execution.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
tool_name: Fully-qualified tool name (e.g. ``"windows.click"``).
|
|
16
|
+
config: The config dict passed to the tool.
|
|
17
|
+
result: Tool return value (``None`` on error).
|
|
18
|
+
error: Exception if the tool raised, else ``None``.
|
|
19
|
+
duration_ms: Wall-clock execution time in milliseconds.
|
|
20
|
+
timestamp: UTC timestamp of event creation.
|
|
21
|
+
metadata: Arbitrary data — middleware can attach session IDs,
|
|
22
|
+
tracing info, etc. Merged from ``__meta__`` in tool results.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
tool_name: str
|
|
26
|
+
config: dict[str, Any]
|
|
27
|
+
result: Any = None
|
|
28
|
+
error: Exception | None = None
|
|
29
|
+
duration_ms: float = 0.0
|
|
30
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
31
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class Middleware(Protocol):
|
|
36
|
+
"""Protocol for event middleware.
|
|
37
|
+
|
|
38
|
+
A middleware receives a :class:`ToolEvent`, may transform it, and
|
|
39
|
+
returns it for the next middleware. Return ``None`` to stop
|
|
40
|
+
propagation.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def __call__(self, event: ToolEvent) -> ToolEvent | None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EventBus:
|
|
47
|
+
"""Ordered middleware pipeline for tool events."""
|
|
48
|
+
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._middlewares: list[Middleware] = []
|
|
51
|
+
|
|
52
|
+
def add_middleware(self, middleware: Middleware) -> None:
|
|
53
|
+
"""Append a middleware to the pipeline."""
|
|
54
|
+
self._middlewares.append(middleware)
|
|
55
|
+
|
|
56
|
+
async def emit(self, event: ToolEvent) -> ToolEvent | None:
|
|
57
|
+
"""Run *event* through the middleware pipeline.
|
|
58
|
+
|
|
59
|
+
Returns the final (possibly transformed) event, or ``None`` if
|
|
60
|
+
a middleware stopped propagation.
|
|
61
|
+
"""
|
|
62
|
+
current: ToolEvent | None = event
|
|
63
|
+
for mw in self._middlewares:
|
|
64
|
+
if current is None:
|
|
65
|
+
return None
|
|
66
|
+
current = await mw(current)
|
|
67
|
+
return current
|