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 +26 -0
- state_mesh/core/__init__.py +0 -0
- state_mesh/core/context.py +122 -0
- state_mesh/core/human_input.py +15 -0
- state_mesh/core/parallel.py +23 -0
- state_mesh/core/pipeline.py +178 -0
- state_mesh/core/step.py +116 -0
- state_mesh/guardrails/__init__.py +0 -0
- state_mesh/guardrails/base.py +20 -0
- state_mesh/guardrails/content.py +91 -0
- state_mesh/guardrails/runner.py +19 -0
- state_mesh/guardrails/schema.py +20 -0
- state_mesh/integrations/__init__.py +0 -0
- state_mesh/integrations/fastapi.py +111 -0
- state_mesh/mcp/__init__.py +0 -0
- state_mesh/mcp/bus.py +25 -0
- state_mesh/mcp/client.py +47 -0
- state_mesh/mcp/registry.py +26 -0
- state_mesh/mcp/server_manager.py +59 -0
- state_mesh/observability/__init__.py +0 -0
- state_mesh/observability/events.py +29 -0
- state_mesh/observability/flags.py +11 -0
- state_mesh/observability/tracer.py +21 -0
- state_mesh/output/__init__.py +0 -0
- state_mesh/output/contract.py +14 -0
- state_mesh/output/parser.py +91 -0
- state_mesh/output/retry.py +38 -0
- state_mesh/state/__init__.py +0 -0
- state_mesh/state/base.py +19 -0
- state_mesh/state/memory.py +19 -0
- state_mesh/state/redis_backend.py +31 -0
- state_mesh-0.1.0.dist-info/METADATA +88 -0
- state_mesh-0.1.0.dist-info/RECORD +34 -0
- state_mesh-0.1.0.dist-info/WHEEL +4 -0
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)
|
state_mesh/core/step.py
ADDED
|
@@ -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
|