state-mesh 0.1.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.
state_mesh/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ from state_mesh.core.context import Context, Flag
2
+ from state_mesh.core.step import Step, Branch, RetryConfig, step
3
+ from state_mesh.core.pipeline import Pipeline, PipelineResult
4
+ from state_mesh.guardrails.base import Guard, GuardResult
5
+ from state_mesh.guardrails.schema import SchemaGuard
6
+ from state_mesh.guardrails.content import PIIGuard
7
+ from state_mesh.output.contract import OutputContract
8
+ from state_mesh.mcp.server_manager import MCPServerConfig, MCPServerManager
9
+
10
+ __all__ = [
11
+ "step",
12
+ "Step",
13
+ "Pipeline",
14
+ "Context",
15
+ "RetryConfig",
16
+ "Branch",
17
+ "PipelineResult",
18
+ "Guard",
19
+ "GuardResult",
20
+ "SchemaGuard",
21
+ "PIIGuard",
22
+ "OutputContract",
23
+ "Flag",
24
+ "MCPServerConfig",
25
+ "MCPServerManager",
26
+ ]
File without changes
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Generic, TypeVar, Literal, TYPE_CHECKING
6
+ from pydantic import BaseModel, Field
7
+
8
+ if TYPE_CHECKING:
9
+ from bus import MCPBus
10
+
11
+ StateSchema = TypeVar("StateSchema", bound=BaseModel)
12
+
13
+
14
+ class Flag(BaseModel):
15
+ step_name:str
16
+ severity: Literal["info","warn","error"]
17
+ timestamp:datetime=Field(default_factory=lambda:datetime.now(timezone.utc))
18
+ flag_type: str
19
+ reason:str
20
+ payload:dict[str,Any]=Field(default_factory=dict)
21
+
22
+ class ContextMutationError(Exception):
23
+ pass
24
+
25
+ class Context(Generic[StateSchema]):
26
+ """
27
+ Context is the main object that is passed to all steps.
28
+ It contains the state, run_id, trace_id, pipeline_name and flags.
29
+ """
30
+ def __init__(self,state,run_id=None,trace_id=None,pipeline_name="unnamed",tools:MCPBus|None=None):
31
+ self._state = state
32
+ self._run_id = run_id or str(uuid.uuid4())
33
+ self._trace_id = trace_id or str(uuid.uuid4())
34
+ self._pipeline_name = pipeline_name
35
+ self._flags:list[Flag] = []
36
+ self._extras:dict[str,Any]={}
37
+ self._tools=tools
38
+ self._step_name=None
39
+
40
+ @property
41
+ def state(self) -> StateSchema:
42
+ return self._state
43
+
44
+ @property
45
+ def tools(self) -> MCPBus|None:
46
+ return self._tools
47
+
48
+ @property
49
+ def run_id(self) -> str:
50
+ return self._run_id
51
+
52
+ @property
53
+ def trace_id(self) -> str:
54
+ return self._trace_id
55
+
56
+ @property
57
+ def pipeline_name(self) -> str:
58
+ return self._pipeline_name
59
+
60
+ @property
61
+ def flags(self) -> list[Flag]:
62
+ flag=self._flags.copy() # sp the user wont be able directly modify this
63
+ return flag
64
+
65
+ def set(self,key:str,value:Any)->None:
66
+ if key in self._extras:
67
+ raise ContextMutationError(f"Key {key} already exists in extras")
68
+ self._extras[key]=value
69
+
70
+ def get(self,key:str,default:Any=None)->Any:
71
+ return self._extras.get(key,default)
72
+
73
+ def emit_flag(self,flag_type:str,reason:str,severity:Literal["info","warn","error"]="warn",payload:dict[str,Any]|None=None)->None:
74
+ self._flags.append(Flag(
75
+ step_name=self._step_name,
76
+ severity=severity,
77
+ flag_type=flag_type,
78
+ reason=reason,
79
+ payload=payload or {}
80
+ ))
81
+
82
+ def _set_current_step(self, step_name: str) -> None:
83
+ self._step_name = step_name
84
+
85
+ def _replace_state(self, new_state: StateSchema) -> None:
86
+ self._state = new_state
87
+
88
+ #serialize the context for pipeline resumption
89
+ def to_dict(self) -> dict[str, Any]:
90
+ return {
91
+ "state": self._state.model_dump() if isinstance(self._state, BaseModel) else self._state,
92
+ "run_id": self._run_id,
93
+ "trace_id": self._trace_id,
94
+ "pipeline_name": self._pipeline_name,
95
+ "extras": self._extras,
96
+ "flags": [f.model_dump() for f in self._flags],
97
+ "last_completed_step": self._step_name,
98
+ }
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: dict[str, Any], state_schema: type[BaseModel]) -> Context:
102
+ ctx = cls(
103
+ state=state_schema(**data["state"]),
104
+ run_id=data["run_id"],
105
+ trace_id=data["trace_id"],
106
+ pipeline_name=data["pipeline_name"],
107
+ )
108
+ ctx._extras = data["extras"]
109
+ ctx._flags = [Flag(**f) for f in data["flags"]]
110
+ ctx._step_name = data.get("last_completed_step")
111
+ return ctx
112
+
113
+ def __repr__(self) -> str:
114
+ return (
115
+ f"Context(run_id={self._run_id!r}, "
116
+ f"step={self._step_name!r}, "
117
+ f"flags={len(self._flags)})"
118
+ )
119
+
120
+
121
+
122
+
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class HumanInput:
5
+ def __init__(
6
+ self,
7
+ prompt: str,
8
+ response_key: str,
9
+ timeout_seconds: float = 300.0,
10
+ fallback_step: str | None = None,
11
+ ):
12
+ self.prompt = prompt
13
+ self.response_key = response_key
14
+ self.timeout_seconds = timeout_seconds
15
+ self.fallback_step = fallback_step
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from state_mesh.core.context import Context
6
+ from state_mesh.core.step import Step, StepResult
7
+
8
+
9
+ class Parallel:
10
+ def __init__(self, steps: list[Step], name: str = "parallel"):
11
+ self.name = name
12
+ self.steps = steps
13
+
14
+ async def execute(self, ctx: Context) -> list[StepResult]:
15
+ results: list[StepResult] = list(
16
+ await asyncio.gather(*[step.execute(ctx) for step in self.steps], return_exceptions=True)
17
+ )
18
+
19
+ for step, result in zip(self.steps, results):
20
+ if not isinstance(result, BaseException) and result.status == "success":
21
+ ctx.set(step.name, result.output)
22
+
23
+ return results
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from state_mesh.core.context import Context, Flag
10
+ from state_mesh.core.step import StepResult, Step, Branch
11
+ from state_mesh.core.human_input import HumanInput
12
+ from state_mesh.core.parallel import Parallel
13
+ from state_mesh.observability.events import EventEmitter
14
+ from state_mesh.observability.tracer import get_tracer
15
+ from state_mesh.observability.flags import attach_flags_to_span
16
+ from state_mesh.mcp.bus import MCPBus
17
+
18
+
19
+ class PipelineResult(BaseModel):
20
+ output: Any
21
+ run_id: str
22
+ trace_id: str
23
+ duration_ms: float
24
+ status: Literal["success", "failed", "timed_out", "guarded"]
25
+ step_results: list[StepResult]
26
+ flags: list[Flag]
27
+
28
+
29
+ class Pipeline:
30
+ def __init__(self, steps: list[Step], name: str = None, state_backend: Any = None, mcp_servers: list[str] | None = None):
31
+ self.name = name
32
+ self.steps = steps
33
+ self.state_backend = state_backend
34
+ self.mcp_servers = mcp_servers
35
+ self._step_map: dict[str, Step] = {s.name: s for s in steps}
36
+ self._emitter = EventEmitter()
37
+
38
+ async def run(self, ctx: Context, event_callback=None) -> PipelineResult:
39
+ step_results = []
40
+ start = time.perf_counter()
41
+
42
+ bus = None
43
+ if self.mcp_servers:
44
+ bus = MCPBus(self.mcp_servers)
45
+ await bus.start()
46
+ ctx._tools = bus
47
+
48
+ try:
49
+ tracer = get_tracer()
50
+ with tracer.start_as_current_span("pipeline.run") as pipeline_span:
51
+ pipeline_span.set_attribute("pipeline.name", self.name or "unnamed")
52
+ pipeline_span.set_attribute("run_id", ctx.run_id)
53
+ pipeline_span.set_attribute("trace_id", ctx.trace_id)
54
+
55
+ self._emitter.pipeline_started(ctx, self.name)
56
+ if event_callback:
57
+ await event_callback("pipeline_started", {"pipeline_name": self.name, "run_id": ctx.run_id})
58
+
59
+ for step in self.steps:
60
+ if isinstance(step, Parallel):
61
+ parallel_results = await step.execute(ctx)
62
+ step_results.extend(parallel_results)
63
+ failed = next((r for r in parallel_results if isinstance(r, BaseException) or r.status != "success"), None)
64
+ if failed:
65
+ duration = (time.perf_counter() - start) * 1000
66
+ status = failed.status if isinstance(failed, StepResult) else "failed"
67
+ self._emitter.pipeline_finished(ctx, self.name, duration, status)
68
+ if event_callback:
69
+ await event_callback("pipeline_finished", {"run_id": ctx.run_id, "status": status, "duration_ms": duration})
70
+ return PipelineResult(output=None, run_id=ctx.run_id, trace_id=ctx.trace_id, duration_ms=duration, status=status, step_results=step_results, flags=ctx.flags)
71
+ continue
72
+
73
+ self._emitter.step_started(ctx, step.name)
74
+ if event_callback:
75
+ await event_callback("step_started", {"step_name": step.name, "run_id": ctx.run_id})
76
+ with tracer.start_as_current_span(f"step.{step.name}") as step_span:
77
+ step_span.set_attribute("step.name", step.name)
78
+ step_span.set_attribute("run_id", ctx.run_id)
79
+ result = await step.execute(ctx)
80
+ step_span.set_attribute("step.status", result.status)
81
+ step_span.set_attribute("step.attempts", result.attempts)
82
+ step_span.set_attribute("step.duration_ms", result.duration_ms)
83
+ attach_flags_to_span(result.flags)
84
+
85
+ self._emitter.step_finished(ctx, step.name, result.duration_ms, result.status)
86
+ if event_callback:
87
+ await event_callback("step_finished", {"step_name": step.name, "run_id": ctx.run_id, "status": result.status, "duration_ms": result.duration_ms})
88
+ step_results.append(result)
89
+
90
+ if result.status != "success":
91
+ duration = (time.perf_counter() - start) * 1000
92
+ self._emitter.pipeline_finished(ctx, self.name, duration, result.status)
93
+ if event_callback:
94
+ await event_callback("pipeline_finished", {"run_id": ctx.run_id, "status": result.status, "duration_ms": duration})
95
+ return PipelineResult(output=result.output, run_id=ctx.run_id, trace_id=ctx.trace_id, duration_ms=duration, status=result.status, step_results=step_results, flags=ctx.flags)
96
+
97
+ if isinstance(result.output, HumanInput):
98
+ hi = result.output
99
+ if event_callback:
100
+ await event_callback("human_input_requested", {"run_id": ctx.run_id, "prompt": hi.prompt, "response_key": hi.response_key})
101
+ from state_mesh.integrations.fastapi import _pending_inputs
102
+ loop = asyncio.get_event_loop()
103
+ future: asyncio.Future = loop.create_future()
104
+ _pending_inputs[ctx.run_id] = future
105
+ try:
106
+ response = await asyncio.wait_for(asyncio.shield(future), timeout=hi.timeout_seconds)
107
+ ctx.set(hi.response_key, response)
108
+ except asyncio.TimeoutError:
109
+ _pending_inputs.pop(ctx.run_id, None)
110
+ if hi.fallback_step and hi.fallback_step in self._step_map:
111
+ result = await self._step_map[hi.fallback_step].execute(ctx)
112
+ step_results.append(result)
113
+ duration = (time.perf_counter() - start) * 1000
114
+ self._emitter.pipeline_finished(ctx, self.name, duration, "timed_out")
115
+ if event_callback:
116
+ await event_callback("pipeline_finished", {"run_id": ctx.run_id, "status": "timed_out", "duration_ms": duration})
117
+ return PipelineResult(output=None, run_id=ctx.run_id, trace_id=ctx.trace_id, duration_ms=duration, status="timed_out", step_results=step_results, flags=ctx.flags)
118
+ finally:
119
+ _pending_inputs.pop(ctx.run_id, None)
120
+ continue
121
+
122
+ if isinstance(result.output, Branch):
123
+ branch = result.output
124
+ ctx = branch.context
125
+ if branch.to not in self._step_map:
126
+ raise ValueError(f"Branch target to {branch.to} not found in pipeline steps")
127
+ self._emitter.step_started(ctx, branch.to)
128
+ if event_callback:
129
+ await event_callback("step_started", {"step_name": branch.to, "run_id": ctx.run_id})
130
+ with tracer.start_as_current_span(f"step.{branch.to}") as step_span:
131
+ step_span.set_attribute("step.name", branch.to)
132
+ step_span.set_attribute("run_id", ctx.run_id)
133
+ result = await self._step_map[branch.to].execute(ctx)
134
+ step_span.set_attribute("step.status", result.status)
135
+ step_span.set_attribute("step.attempts", result.attempts)
136
+ step_span.set_attribute("step.duration_ms", result.duration_ms)
137
+ attach_flags_to_span(result.flags)
138
+
139
+ self._emitter.step_finished(ctx, branch.to, result.duration_ms, result.status)
140
+ if event_callback:
141
+ await event_callback("step_finished", {"step_name": branch.to, "run_id": ctx.run_id, "status": result.status, "duration_ms": result.duration_ms})
142
+ step_results.append(result)
143
+ if result.status != "success":
144
+ duration = (time.perf_counter() - start) * 1000
145
+ self._emitter.pipeline_finished(ctx, self.name, duration, result.status)
146
+ if event_callback:
147
+ await event_callback("pipeline_finished", {"run_id": ctx.run_id, "status": result.status, "duration_ms": duration})
148
+ return PipelineResult(output=result.output, run_id=ctx.run_id, trace_id=ctx.trace_id, duration_ms=duration, status=result.status, step_results=step_results, flags=ctx.flags)
149
+ continue
150
+
151
+ ctx._replace_state(result.output)
152
+ if self.state_backend:
153
+ await self.state_backend.save(ctx.run_id, ctx.to_dict())
154
+
155
+ duration = (time.perf_counter() - start) * 1000
156
+ self._emitter.pipeline_finished(ctx, self.name, duration, "success")
157
+ if event_callback:
158
+ await event_callback("pipeline_finished", {"run_id": ctx.run_id, "status": "success", "duration_ms": duration})
159
+ if self.state_backend:
160
+ await self.state_backend.delete(ctx.run_id)
161
+ return PipelineResult(output=step_results[-1].output if step_results else None, run_id=ctx.run_id, trace_id=ctx.trace_id, duration_ms=duration, status="success", step_results=step_results, flags=ctx.flags)
162
+ finally:
163
+ if bus:
164
+ await bus.stop()
165
+
166
+ async def resume(self, run_id: str, state_schema: type[BaseModel]) -> PipelineResult:
167
+ if not self.state_backend:
168
+ raise RuntimeError("Cannot resume — no state_backend configured")
169
+ data = await self.state_backend.load(run_id)
170
+ ctx = Context.from_dict(data, state_schema)
171
+
172
+ last = ctx._step_name
173
+ step_names = [s.name for s in self.steps]
174
+ start_index = step_names.index(last) + 1 if last in step_names else 0
175
+
176
+ remaining = self.steps[start_index:]
177
+ pipeline = Pipeline(steps=remaining, name=self.name, state_backend=self.state_backend, mcp_servers=self.mcp_servers)
178
+ return await pipeline.run(ctx)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal, Optional, Callable
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from state_mesh.core.context import Context, Flag
11
+ from state_mesh.guardrails.runner import run_guards
12
+ from state_mesh.output.retry import run_with_retry
13
+ from state_mesh.output.parser import Parser
14
+ from state_mesh.output.contract import OutputContract
15
+
16
+
17
+ class StepResult(BaseModel):
18
+ status: Literal["success", "failed", "timed_out", "guarded"]
19
+ step_name: str
20
+ output: Any
21
+ duration_ms: float
22
+ attempts: int
23
+ flags: list[Flag] = Field(default_factory=list)
24
+ error: Optional[str] = None
25
+
26
+
27
+ @dataclass
28
+ class RetryConfig:
29
+ max_attempts: int = 3
30
+ backoff_base: float = 1.0
31
+ backoff_multiplier: float = 2.0
32
+
33
+
34
+ class Branch:
35
+ def __init__(self, to: str, context: Context):
36
+ self.to = to
37
+ self.context = context
38
+
39
+
40
+ class Step:
41
+ def __init__(self, fn: Callable, name: str | None = None, retry_config: RetryConfig = None,
42
+ timeout_seconds: Optional[float] = None, guard_before: list = None, guard_after: list = None,
43
+ tags: Optional[list[str]] = None, output_contract: Optional[OutputContract] = None):
44
+ self.name = name or fn.__name__
45
+ self.fn = fn
46
+ self.retry_config = retry_config or RetryConfig()
47
+ self.timeout_seconds = timeout_seconds
48
+ self.guard_before = guard_before or []
49
+ self.guard_after = guard_after or []
50
+ self.tags = tags or []
51
+ self.output_contract = output_contract
52
+
53
+ async def execute(self, ctx: Context, prompt: str | None = None) -> StepResult:
54
+ start = time.perf_counter()
55
+ last_error = None
56
+ ctx._set_current_step(self.name)
57
+
58
+ if self.guard_before:
59
+ guard_result = await run_guards(self.guard_before, ctx, ctx.state)
60
+ if not guard_result.passed:
61
+ elapsed = (time.perf_counter() - start) * 1000
62
+ return StepResult(status="guarded", flags=ctx.flags, step_name=self.name, output=None, duration_ms=elapsed, attempts=0, error=guard_result.reason)
63
+
64
+ for attempt in range(1, self.retry_config.max_attempts + 1):
65
+ try:
66
+ if self.output_contract is not None:
67
+ coro = run_with_retry(self.fn, prompt, self.output_contract, Parser())
68
+ if self.timeout_seconds:
69
+ result = await asyncio.wait_for(coro, timeout=self.timeout_seconds)
70
+ else:
71
+ result = await coro
72
+ elif self.timeout_seconds:
73
+ result = await asyncio.wait_for(self.fn(ctx), timeout=self.timeout_seconds)
74
+ else:
75
+ result = await self.fn(ctx)
76
+
77
+ if self.guard_after:
78
+ guard_result = await run_guards(self.guard_after, ctx, result)
79
+ if not guard_result.passed:
80
+ elapsed = (time.perf_counter() - start) * 1000
81
+ return StepResult(status="guarded", flags=ctx.flags, step_name=self.name, output=None, duration_ms=elapsed, attempts=attempt, error=guard_result.reason)
82
+
83
+ elapsed = (time.perf_counter() - start) * 1000
84
+
85
+ if isinstance(result, Branch):
86
+ return StepResult(status="success", flags=ctx.flags, step_name=self.name, output=result, duration_ms=elapsed, attempts=attempt)
87
+
88
+ return StepResult(status="success", flags=ctx.flags, step_name=self.name, output=result, duration_ms=elapsed, attempts=attempt)
89
+ except asyncio.TimeoutError:
90
+ elapsed = (time.perf_counter() - start) * 1000
91
+ return StepResult(
92
+ status="timed_out",
93
+ flags=ctx.flags,
94
+ step_name=self.name,
95
+ output=None,
96
+ duration_ms=elapsed,
97
+ attempts=attempt,
98
+ error=f"Step timed out after {self.timeout_seconds} seconds"
99
+ )
100
+ except Exception as e:
101
+ last_error = e
102
+ if attempt < self.retry_config.max_attempts:
103
+ wait = self.retry_config.backoff_base * (self.retry_config.backoff_multiplier ** (attempt - 1))
104
+ await asyncio.sleep(wait)
105
+ continue
106
+
107
+ elapsed = (time.perf_counter() - start) * 1000
108
+ return StepResult(status="failed", flags=ctx.flags, step_name=self.name, output=None, duration_ms=elapsed, attempts=self.retry_config.max_attempts, error=str(last_error))
109
+
110
+
111
+ def step(fn=None, *, name=None, timeout_seconds=None, retry_config=None, guard_before=None, guard_after=None, tags=None, output_contract=None):
112
+ if fn is not None:
113
+ return Step(fn=fn)
114
+ def wrap(f):
115
+ return Step(fn=f, name=name, timeout_seconds=timeout_seconds, retry_config=retry_config, guard_before=guard_before, guard_after=guard_after, tags=tags, output_contract=output_contract)
116
+ return wrap
File without changes
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+ from abc import ABC, abstractmethod
6
+
7
+ from state_mesh.core.context import Context
8
+
9
+
10
+ @dataclass
11
+ class GuardResult:
12
+ passed: bool
13
+ reason: str
14
+ severity: Literal["warn", "block"] = "warn"
15
+
16
+
17
+ class Guard(ABC):
18
+ @abstractmethod
19
+ async def check(self, ctx: Context, data: Any) -> GuardResult:
20
+ pass
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Literal
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from state_mesh.guardrails.base import Guard, GuardResult
9
+ from state_mesh.core.context import Context
10
+
11
+ try:
12
+ from detoxify import Detoxify
13
+ except ImportError: # pragma: no cover - optional dependency
14
+ class Detoxify: # type: ignore[no-redef]
15
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
16
+ raise ImportError("detoxify is required to use ToxicityGuard")
17
+
18
+ EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
19
+
20
+
21
+ class PIIGuard(Guard):
22
+ def __init__(
23
+ self,
24
+ sensitive_fields: list[str] | None = None,
25
+ ignore_patterns: list[str] | None = None,
26
+ severity: Literal["warn", "block"] = "block",
27
+ ):
28
+ self.sensitive_fields = sensitive_fields
29
+ self.ignore_patterns = [re.compile(p) for p in (ignore_patterns or [])]
30
+ self.severity = severity
31
+
32
+ async def check(self, ctx: Context, data: Any) -> GuardResult:
33
+ if self.sensitive_fields and isinstance(data, BaseModel):
34
+ candidates = {
35
+ f: str(v)
36
+ for f, v in data.model_dump().items()
37
+ if f in self.sensitive_fields
38
+ }
39
+ else:
40
+ candidates = {"data": data if isinstance(data, str) else str(data)}
41
+
42
+ for field, value in candidates.items():
43
+ if any(p.search(value) for p in self.ignore_patterns):
44
+ continue
45
+ if EMAIL_PATTERN.search(value):
46
+ return GuardResult(passed=False, severity=self.severity, reason=f"Email detected in field '{field}'")
47
+
48
+ return GuardResult(passed=True, reason="No PII detected")
49
+
50
+
51
+ class ConfidenceGuard(Guard):
52
+ def __init__(self, min_confidence: float, field: str = "confidence", severity: Literal["warn", "block"] = "block"):
53
+ self.min_confidence = min_confidence
54
+ self.field = field
55
+ self.severity = severity
56
+
57
+ async def check(self, ctx: Context, data: Any) -> GuardResult:
58
+ if isinstance(data, BaseModel):
59
+ score = data.model_dump().get(self.field)
60
+ elif isinstance(data, dict):
61
+ score = data.get(self.field)
62
+ else:
63
+ return GuardResult(passed=False, severity=self.severity, reason=f"Cannot extract confidence field '{self.field}' from {type(data).__name__}")
64
+
65
+ if score is None:
66
+ return GuardResult(passed=False, severity=self.severity, reason=f"Confidence field '{self.field}' missing")
67
+
68
+ if score < self.min_confidence:
69
+ return GuardResult(passed=False, severity=self.severity, reason=f"Confidence {score:.2f} below threshold {self.min_confidence:.2f}")
70
+
71
+ return GuardResult(passed=True, reason=f"Confidence {score:.2f} meets threshold")
72
+
73
+
74
+ class ToxicityGuard(Guard):
75
+ def __init__(self, threshold: float = 0.5, categories: list[str] | None = None, severity: Literal["warn", "block"] = "block"):
76
+ self._model = Detoxify("original")
77
+ self.threshold = threshold
78
+ self.categories = categories or ["toxicity", "severe_toxicity", "obscene", "threat", "insult", "identity_attack"]
79
+ self.severity = severity
80
+
81
+ async def check(self, ctx: Context, data: Any) -> GuardResult:
82
+ text = data if isinstance(data, str) else str(data)
83
+ scores = self._model.predict(text)
84
+
85
+ for category in self.categories:
86
+ score = scores.get(category, 0)
87
+ if score >= self.threshold:
88
+ return GuardResult(passed=False, severity=self.severity, reason=f"Toxicity detected — {category}: {score:.2f} (threshold: {self.threshold:.2f})")
89
+
90
+ return GuardResult(passed=True, reason="No toxic content detected")
91
+
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from state_mesh.guardrails.base import Guard, GuardResult
6
+ from state_mesh.core.context import Context
7
+
8
+
9
+ async def run_guards(guards: list[Guard], ctx: Context, data: Any) -> GuardResult:
10
+ for guard in guards:
11
+ result = await guard.check(ctx, data)
12
+
13
+ if not result.passed:
14
+ if result.severity == "block":
15
+ return result
16
+ if result.severity == "warn":
17
+ ctx.emit_flag(flag_type="guard_warn", reason=result.reason, severity="warn")
18
+
19
+ return GuardResult(passed=True, reason="all guards passed")
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Type
4
+
5
+ from pydantic import BaseModel, ValidationError
6
+
7
+ from state_mesh.guardrails.base import Guard, GuardResult
8
+ from state_mesh.core.context import Context
9
+
10
+
11
+ class SchemaGuard(Guard):
12
+ def __init__(self, schema: Type[BaseModel]):
13
+ self.schema = schema
14
+
15
+ async def check(self, ctx: Context, data: Any) -> GuardResult:
16
+ try:
17
+ self.schema.model_validate(data)
18
+ return GuardResult(passed=True, reason="")
19
+ except ValidationError as e:
20
+ return GuardResult(passed=False, reason=str(e))
File without changes