agentstate-lib 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.
- agentstate/__init__.py +63 -0
- agentstate/api/__init__.py +0 -0
- agentstate/api/app.py +0 -0
- agentstate/api/auth.py +0 -0
- agentstate/api/cache.py +0 -0
- agentstate/api/routes.py +0 -0
- agentstate/api/streaming.py +0 -0
- agentstate/contrib/__init__.py +0 -0
- agentstate/contrib/base_agent.py +0 -0
- agentstate/coordination/__init__.py +0 -0
- agentstate/coordination/conflicts.py +0 -0
- agentstate/coordination/invariants.py +0 -0
- agentstate/core/__init__.py +30 -0
- agentstate/core/events.py +74 -0
- agentstate/core/patch.py +59 -0
- agentstate/core/state.py +49 -0
- agentstate/memory/__init__.py +11 -0
- agentstate/memory/checkpoint.py +0 -0
- agentstate/memory/replay.py +0 -0
- agentstate/memory/store.py +134 -0
- agentstate/observability/__init__.py +0 -0
- agentstate/observability/analysis.py +0 -0
- agentstate/observability/dashboard.py +0 -0
- agentstate/observability/tracing.py +0 -0
- agentstate/router/__init__.py +12 -0
- agentstate/router/context.py +24 -0
- agentstate/router/graph.py +162 -0
- agentstate/router/types.py +15 -0
- agentstate_lib-0.1.0.dist-info/METADATA +33 -0
- agentstate_lib-0.1.0.dist-info/RECORD +32 -0
- agentstate_lib-0.1.0.dist-info/WHEEL +4 -0
- agentstate_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
agentstate/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentstate.core.events import (
|
|
4
|
+
AgentErrored,
|
|
5
|
+
BaseStateEvent,
|
|
6
|
+
CheckpointSaved,
|
|
7
|
+
ConflictDetected,
|
|
8
|
+
PatchApplied,
|
|
9
|
+
StateEvent,
|
|
10
|
+
WorkflowCompleted,
|
|
11
|
+
WorkflowStarted,
|
|
12
|
+
event_adapter,
|
|
13
|
+
)
|
|
14
|
+
from agentstate.core.patch import (
|
|
15
|
+
StatePatch,
|
|
16
|
+
apply_patch,
|
|
17
|
+
get_nested,
|
|
18
|
+
set_nested,
|
|
19
|
+
)
|
|
20
|
+
from agentstate.core.state import (
|
|
21
|
+
Artifact,
|
|
22
|
+
Decision,
|
|
23
|
+
Goal,
|
|
24
|
+
SharedState,
|
|
25
|
+
Task,
|
|
26
|
+
WorkflowStatus,
|
|
27
|
+
)
|
|
28
|
+
from agentstate.memory.store import InMemoryStore, SQLiteStore, StateStore
|
|
29
|
+
from agentstate.router.context import slice_state
|
|
30
|
+
from agentstate.router.graph import AgentGraph
|
|
31
|
+
from agentstate.router.types import AgentFn, EdgeCondition
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"SharedState",
|
|
37
|
+
"Task",
|
|
38
|
+
"Goal",
|
|
39
|
+
"Artifact",
|
|
40
|
+
"Decision",
|
|
41
|
+
"WorkflowStatus",
|
|
42
|
+
"StatePatch",
|
|
43
|
+
"apply_patch",
|
|
44
|
+
"set_nested",
|
|
45
|
+
"get_nested",
|
|
46
|
+
"StateEvent",
|
|
47
|
+
"BaseStateEvent",
|
|
48
|
+
"WorkflowStarted",
|
|
49
|
+
"WorkflowCompleted",
|
|
50
|
+
"PatchApplied",
|
|
51
|
+
"ConflictDetected",
|
|
52
|
+
"CheckpointSaved",
|
|
53
|
+
"AgentErrored",
|
|
54
|
+
"event_adapter",
|
|
55
|
+
"AgentGraph",
|
|
56
|
+
"AgentFn",
|
|
57
|
+
"EdgeCondition",
|
|
58
|
+
"slice_state",
|
|
59
|
+
"StateStore",
|
|
60
|
+
"InMemoryStore",
|
|
61
|
+
"SQLiteStore",
|
|
62
|
+
"__version__",
|
|
63
|
+
]
|
|
File without changes
|
agentstate/api/app.py
ADDED
|
File without changes
|
agentstate/api/auth.py
ADDED
|
File without changes
|
agentstate/api/cache.py
ADDED
|
File without changes
|
agentstate/api/routes.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .events import (
|
|
2
|
+
AgentErrored,
|
|
3
|
+
BaseStateEvent,
|
|
4
|
+
CheckpointSaved,
|
|
5
|
+
ConflictDetected,
|
|
6
|
+
PatchApplied,
|
|
7
|
+
StateEvent,
|
|
8
|
+
WorkflowCompleted,
|
|
9
|
+
WorkflowStarted,
|
|
10
|
+
event_adapter,
|
|
11
|
+
)
|
|
12
|
+
from .state import Artifact, Decision, Goal, SharedState, Task, WorkflowStatus
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SharedState",
|
|
16
|
+
"Task",
|
|
17
|
+
"Goal",
|
|
18
|
+
"Artifact",
|
|
19
|
+
"Decision",
|
|
20
|
+
"WorkflowStatus",
|
|
21
|
+
"StateEvent",
|
|
22
|
+
"BaseStateEvent",
|
|
23
|
+
"WorkflowStarted",
|
|
24
|
+
"WorkflowCompleted",
|
|
25
|
+
"PatchApplied",
|
|
26
|
+
"ConflictDetected",
|
|
27
|
+
"CheckpointSaved",
|
|
28
|
+
"AgentErrored",
|
|
29
|
+
"event_adapter",
|
|
30
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Annotated, Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, TypeAdapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# This is the parent all events share
|
|
9
|
+
class BaseStateEvent(BaseModel):
|
|
10
|
+
event_id : str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
11
|
+
workflow_id : str
|
|
12
|
+
agent_id : str
|
|
13
|
+
timestamp : float = Field(default_factory=time.time)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# six subclasses inherting from BaseStateEvent,
|
|
17
|
+
# with each one, having a 'type' field typed as a literal with
|
|
18
|
+
# a single string value, and set its default to that string.
|
|
19
|
+
|
|
20
|
+
class WorkflowStarted(BaseStateEvent):
|
|
21
|
+
type : Literal["workflow_started"]
|
|
22
|
+
workflow_type : str
|
|
23
|
+
goal : str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WorkflowCompleted(BaseStateEvent):
|
|
27
|
+
type : Literal["workflow_completed"]
|
|
28
|
+
final_status : Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PatchApplied(BaseStateEvent):
|
|
32
|
+
type : Literal["patch_applied"]
|
|
33
|
+
patch_id : str
|
|
34
|
+
target : str
|
|
35
|
+
old_value : Any
|
|
36
|
+
new_value : Any
|
|
37
|
+
reason : str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConflictDetected(BaseStateEvent):
|
|
41
|
+
type : Literal["conflict_detected"]
|
|
42
|
+
conflict_id : str
|
|
43
|
+
path : str
|
|
44
|
+
winner_agent_id : str
|
|
45
|
+
loser_agent_id : str
|
|
46
|
+
resolution_strategy : str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CheckpointSaved(BaseStateEvent):
|
|
50
|
+
type : Literal["checkpoint_saved"]
|
|
51
|
+
checkpoint_id : str
|
|
52
|
+
event_count : int
|
|
53
|
+
|
|
54
|
+
class AgentErrored(BaseStateEvent):
|
|
55
|
+
type : Literal["agent_errored"]
|
|
56
|
+
error_type : str
|
|
57
|
+
error_message : str
|
|
58
|
+
retry_count : int
|
|
59
|
+
|
|
60
|
+
# Pydantic will use the type field to decide which model to instantiate
|
|
61
|
+
# when deserializing
|
|
62
|
+
StateEvent = Annotated[
|
|
63
|
+
WorkflowStarted
|
|
64
|
+
| WorkflowCompleted
|
|
65
|
+
| PatchApplied
|
|
66
|
+
| ConflictDetected
|
|
67
|
+
| CheckpointSaved
|
|
68
|
+
| AgentErrored,
|
|
69
|
+
Field(discriminator="type")
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# at module level, writing a typeadapter for statevent
|
|
73
|
+
# this is how you deserialize a JSON string into the correct event subtype
|
|
74
|
+
event_adapter: TypeAdapter[StateEvent] = TypeAdapter(StateEvent)
|
agentstate/core/patch.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from agentstate.core.state import SharedState
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# represents "one change an agent wants to make" to the shared state
|
|
13
|
+
class StatePatch(BaseModel):
|
|
14
|
+
patch_id:str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
15
|
+
agent_id: str
|
|
16
|
+
target : str # dotted path, eg: tasks.task_1.status
|
|
17
|
+
value : Any
|
|
18
|
+
reason : str
|
|
19
|
+
timestamp : float = Field(default_factory=time.time)
|
|
20
|
+
priority : int = 0
|
|
21
|
+
|
|
22
|
+
def set_nested(obj: dict[str, Any], path: str, value : Any) -> dict[str, Any]:
|
|
23
|
+
"""
|
|
24
|
+
Set value at a dotted path inside a nested dict, creating dicts as needed.
|
|
25
|
+
"""
|
|
26
|
+
parts = path.split(".") # turns "a.b.c" into ["a","b","c"]
|
|
27
|
+
current : dict[str, Any] = obj
|
|
28
|
+
|
|
29
|
+
for key in parts[:-1]:
|
|
30
|
+
if key not in current or not isinstance(current[key], dict):
|
|
31
|
+
current[key] = {}
|
|
32
|
+
current = current[key]
|
|
33
|
+
|
|
34
|
+
current[parts[-1]] = value
|
|
35
|
+
return obj
|
|
36
|
+
|
|
37
|
+
def get_nested(obj: dict[str, Any], path: str) -> Any:
|
|
38
|
+
"""
|
|
39
|
+
Get value at a dotted path from a nested dict, or None if missing.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
parts = path.split(".")
|
|
43
|
+
current : Any = obj
|
|
44
|
+
|
|
45
|
+
for key in parts:
|
|
46
|
+
if not isinstance(current, dict):
|
|
47
|
+
return None
|
|
48
|
+
if key not in current:
|
|
49
|
+
return None
|
|
50
|
+
current = current[key]
|
|
51
|
+
|
|
52
|
+
return current
|
|
53
|
+
|
|
54
|
+
# creates a dict view of SharedState with model_dump()
|
|
55
|
+
def apply_patch(state: SharedState, patch: StatePatch) -> SharedState:
|
|
56
|
+
"""Return a new SharedState with the patch applied at patch.target"""
|
|
57
|
+
state_dict = state.model_dump()
|
|
58
|
+
set_nested(state_dict, patch.target, patch.value)
|
|
59
|
+
return SharedState.model_validate(state_dict)
|
agentstate/core/state.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
WorkflowStatus = Literal["running", "complete", "failed", "paused"]
|
|
8
|
+
|
|
9
|
+
class Task(BaseModel):
|
|
10
|
+
id : str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
11
|
+
description : str
|
|
12
|
+
status : Literal["pending", "running", "done", "failed"] = 'pending'
|
|
13
|
+
result : Any | None = None
|
|
14
|
+
created_at : float = Field(default_factory=time.time)
|
|
15
|
+
updated_as: float = Field(default_factory=time.time)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Goal(BaseModel):
|
|
19
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
20
|
+
description : str
|
|
21
|
+
status : Literal["pending", "active", "complete", "failed"] = 'pending'
|
|
22
|
+
created_at : float = Field(default_factory=time.time)
|
|
23
|
+
|
|
24
|
+
class Artifact(BaseModel):
|
|
25
|
+
id : str = Field(default_factory=lambda : str(uuid.uuid4()))
|
|
26
|
+
produced_by : str
|
|
27
|
+
artifact_type : str
|
|
28
|
+
content : Any
|
|
29
|
+
created_at : float = Field(default_factory=time.time)
|
|
30
|
+
|
|
31
|
+
class Decision(BaseModel):
|
|
32
|
+
id : str = Field(default_factory=lambda : str(uuid.uuid4()))
|
|
33
|
+
made_by : str
|
|
34
|
+
description : str
|
|
35
|
+
rationale : str
|
|
36
|
+
timestamp : float = Field(default_factory=time.time)
|
|
37
|
+
|
|
38
|
+
class SharedState(BaseModel):
|
|
39
|
+
workflow_id : str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
40
|
+
workflow_type : str = "general"
|
|
41
|
+
goal : str
|
|
42
|
+
goals : dict[str, Goal] = Field(default_factory=dict)
|
|
43
|
+
tasks : dict[str, Task] = Field(default_factory=dict)
|
|
44
|
+
artifacts : dict[str, Artifact] = Field(default_factory=dict)
|
|
45
|
+
decisions : list[Decision] = Field(default_factory=list)
|
|
46
|
+
facts : dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
status : WorkflowStatus = 'running'
|
|
48
|
+
created_at : float = Field(default_factory=time.time)
|
|
49
|
+
updated_at : float = Field(default_factory=time.time)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
import aiosqlite
|
|
4
|
+
|
|
5
|
+
from agentstate.core.events import StateEvent, event_adapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Protocol : a rule sheet for what a store must be able to do
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class StateStore(Protocol):
|
|
11
|
+
async def append(self, event: StateEvent) -> None:
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
async def get_workflow(self, workflow_id: str) -> list[StateEvent]:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
async def since(self, workflow_id: str, index: int) -> list[StateEvent]:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
async def count(self, workflow_id: str) -> int:
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
class InMemoryStore:
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._events: dict[str, list[StateEvent]] = {}
|
|
26
|
+
|
|
27
|
+
async def append(self, event: StateEvent) -> None:
|
|
28
|
+
if event.workflow_id not in self._events:
|
|
29
|
+
self._events[event.workflow_id] = []
|
|
30
|
+
self._events[event.workflow_id].append(event)
|
|
31
|
+
|
|
32
|
+
async def get_workflow(self, workflow_id: str) -> list[StateEvent]:
|
|
33
|
+
return list(self._events.get(workflow_id, []))
|
|
34
|
+
|
|
35
|
+
async def since(self, workflow_id : str, index: int) -> list[StateEvent]:
|
|
36
|
+
return list(self._events.get(workflow_id, [])[index:])
|
|
37
|
+
|
|
38
|
+
async def count(self, workflow_id: str) -> int:
|
|
39
|
+
return len(self._events.get(workflow_id, []))
|
|
40
|
+
|
|
41
|
+
# this method should create the events table if it does not already exists
|
|
42
|
+
# create an index on workflow_id
|
|
43
|
+
# commit the changes
|
|
44
|
+
class SQLiteStore:
|
|
45
|
+
def __init__(self, path: str) -> None:
|
|
46
|
+
self.path = path
|
|
47
|
+
|
|
48
|
+
async def _init_db(self, db:aiosqlite.Connection) -> None:
|
|
49
|
+
await db.execute(
|
|
50
|
+
"""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
event_id TEXT NOT NULL,
|
|
54
|
+
workflow_id TEXT NOT NULL,
|
|
55
|
+
type TEXT NOT NULL,
|
|
56
|
+
data TEXT NOT NULL,
|
|
57
|
+
timestamp REAL NOT NULL
|
|
58
|
+
)
|
|
59
|
+
"""
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
await db.execute(
|
|
63
|
+
"""
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_wf_id
|
|
65
|
+
ON events(workflow_id)
|
|
66
|
+
"""
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await db.commit()
|
|
70
|
+
|
|
71
|
+
async def append(self, event: StateEvent) -> None:
|
|
72
|
+
async with aiosqlite.connect(self.path) as db:
|
|
73
|
+
await self._init_db(db)
|
|
74
|
+
await db.execute(
|
|
75
|
+
"""
|
|
76
|
+
INSERT INTO events(event_id, workflow_id, type, data, timestamp)
|
|
77
|
+
VALUES (?, ?, ?, ?, ?)
|
|
78
|
+
""",
|
|
79
|
+
(
|
|
80
|
+
event.event_id,
|
|
81
|
+
event.workflow_id,
|
|
82
|
+
event.type,
|
|
83
|
+
event.model_dump_json(),
|
|
84
|
+
event.timestamp,
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
await db.commit()
|
|
88
|
+
|
|
89
|
+
async def get_workflow(self, workflow_id : str) -> list[StateEvent]:
|
|
90
|
+
async with aiosqlite.connect(self.path) as db:
|
|
91
|
+
await self._init_db(db)
|
|
92
|
+
cursor = await db.execute(
|
|
93
|
+
"""
|
|
94
|
+
SELECT data
|
|
95
|
+
FROM events
|
|
96
|
+
WHERE workflow_id=?
|
|
97
|
+
ORDER BY id ASC
|
|
98
|
+
""",
|
|
99
|
+
(workflow_id,),
|
|
100
|
+
)
|
|
101
|
+
rows = await cursor.fetchall()
|
|
102
|
+
return [event_adapter.validate_json(row[0]) for row in rows]
|
|
103
|
+
|
|
104
|
+
async def since(self, workflow_id: str, index: int) -> list[StateEvent]:
|
|
105
|
+
async with aiosqlite.connect(self.path) as db:
|
|
106
|
+
await self._init_db(db)
|
|
107
|
+
cursor = await db.execute(
|
|
108
|
+
"""
|
|
109
|
+
SELECT data
|
|
110
|
+
FROM events
|
|
111
|
+
WHERE workflow_id=?
|
|
112
|
+
ORDER BY id ASC
|
|
113
|
+
LIMIT -1 OFFSET ?
|
|
114
|
+
""",
|
|
115
|
+
(workflow_id, index),
|
|
116
|
+
)
|
|
117
|
+
rows = await cursor.fetchall()
|
|
118
|
+
return [event_adapter.validate_json(row[0]) for row in rows]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def count(self, workflow_id: str) -> int:
|
|
122
|
+
async with aiosqlite.connect(self.path) as db:
|
|
123
|
+
await self._init_db(db)
|
|
124
|
+
cursor = await db.execute(
|
|
125
|
+
"""
|
|
126
|
+
SELECT COUNT(*)
|
|
127
|
+
FROM events
|
|
128
|
+
WHERE workflow_id=?
|
|
129
|
+
""",
|
|
130
|
+
(workflow_id,),
|
|
131
|
+
)
|
|
132
|
+
row = await cursor.fetchone()
|
|
133
|
+
return int(row[0]) if row else 0
|
|
134
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentstate.router.context import slice_state
|
|
4
|
+
from agentstate.router.graph import AgentGraph
|
|
5
|
+
from agentstate.router.types import AgentFn, EdgeCondition
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentGraph",
|
|
9
|
+
"AgentFn",
|
|
10
|
+
"EdgeCondition",
|
|
11
|
+
"slice_state",
|
|
12
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from agentstate.core.patch import get_nested, set_nested
|
|
6
|
+
from agentstate.core.state import SharedState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def slice_state(state: SharedState, include_paths: list[str]) -> dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Returns a dict view of just the requested paths from SharedState.
|
|
12
|
+
if include_paths is empty, return the full state dict
|
|
13
|
+
"""
|
|
14
|
+
full_dict = state.model_dump()
|
|
15
|
+
# no slice requested mean "full context"
|
|
16
|
+
if not include_paths:
|
|
17
|
+
return full_dict
|
|
18
|
+
|
|
19
|
+
result : dict[str, Any] = {}
|
|
20
|
+
for path in include_paths:
|
|
21
|
+
value = get_nested(full_dict, path)
|
|
22
|
+
if value is not None:
|
|
23
|
+
set_nested(result, path, value)
|
|
24
|
+
return result
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from agentstate.core.events import (
|
|
8
|
+
PatchApplied,
|
|
9
|
+
WorkflowCompleted,
|
|
10
|
+
WorkflowStarted,
|
|
11
|
+
)
|
|
12
|
+
from agentstate.core.patch import apply_patch
|
|
13
|
+
from agentstate.core.state import SharedState
|
|
14
|
+
from agentstate.memory.store import InMemoryStore, StateStore
|
|
15
|
+
from agentstate.router.context import slice_state
|
|
16
|
+
from agentstate.router.types import AgentFn, EdgeCondition
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class _Node:
|
|
21
|
+
agent_id: str
|
|
22
|
+
fn : AgentFn
|
|
23
|
+
context_keys : list[str]
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Edge:
|
|
27
|
+
from_agent : str
|
|
28
|
+
to_agent: str
|
|
29
|
+
condition: EdgeCondition
|
|
30
|
+
|
|
31
|
+
class AgentGraph:
|
|
32
|
+
"""A simple directed graph of agents with conditional edges."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
store: StateStore | None = None,
|
|
37
|
+
max_concurrent: int = 3,
|
|
38
|
+
) ->None:
|
|
39
|
+
self._store: StateStore = store or InMemoryStore()
|
|
40
|
+
self._nodes: dict[str, _Node] = {}
|
|
41
|
+
self._edges: list[_Edge] = []
|
|
42
|
+
self._sem = asyncio.Semaphore(max_concurrent)
|
|
43
|
+
|
|
44
|
+
def node(
|
|
45
|
+
self,
|
|
46
|
+
agent_id: str,
|
|
47
|
+
context: list[str] | None = None,
|
|
48
|
+
) -> Callable[[AgentFn], AgentFn]:
|
|
49
|
+
"""Decorator to register an agent function under a given agent_id"""
|
|
50
|
+
|
|
51
|
+
def decorator(fn:AgentFn) -> AgentFn:
|
|
52
|
+
node = _Node(
|
|
53
|
+
agent_id=agent_id,
|
|
54
|
+
fn=fn,
|
|
55
|
+
context_keys=context or [],
|
|
56
|
+
)
|
|
57
|
+
self._nodes[agent_id] = node
|
|
58
|
+
return fn
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
def edge(
|
|
62
|
+
self,
|
|
63
|
+
from_agent: str,
|
|
64
|
+
to_agent: str,
|
|
65
|
+
condition: EdgeCondition | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Add a directed edge between two agents."""
|
|
68
|
+
cond = condition or (lambda s: True)
|
|
69
|
+
self._edges.append(
|
|
70
|
+
_Edge(
|
|
71
|
+
from_agent=from_agent,
|
|
72
|
+
to_agent=to_agent,
|
|
73
|
+
condition=cond,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _next_agent(self, current_id:str, state:SharedState) -> str | None:
|
|
78
|
+
"""Return the next agent_id to run current_id, or None if done."""
|
|
79
|
+
state_dict = state.model_dump()
|
|
80
|
+
|
|
81
|
+
for edge in self._edges:
|
|
82
|
+
if edge.from_agent != current_id:
|
|
83
|
+
continue
|
|
84
|
+
if edge.condition(state_dict):
|
|
85
|
+
return edge.to_agent
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
async def run(
|
|
90
|
+
self,
|
|
91
|
+
state: SharedState,
|
|
92
|
+
start: str,
|
|
93
|
+
event_queue: asyncio.Queue[PatchApplied | WorkflowStarted
|
|
94
|
+
| WorkflowCompleted] | None = None,
|
|
95
|
+
) -> SharedState:
|
|
96
|
+
"""Run the agent graph starting from the given agent_id"""
|
|
97
|
+
if start not in self._nodes:
|
|
98
|
+
msg = f"Start agent '{start}' is not registered in this AgentGraph."
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
|
|
101
|
+
# for now, use the state's workflow_id as the workflow identifier
|
|
102
|
+
workflow_id = state.workflow_id
|
|
103
|
+
|
|
104
|
+
# Record workflow start
|
|
105
|
+
workflow_started = WorkflowStarted(
|
|
106
|
+
workflow_id=workflow_id,
|
|
107
|
+
agent_id="system",
|
|
108
|
+
workflow_type=state.workflow_type,
|
|
109
|
+
goal=state.goal,
|
|
110
|
+
type="workflow_started",
|
|
111
|
+
)
|
|
112
|
+
await self._store.append(workflow_started)
|
|
113
|
+
if event_queue is not None:
|
|
114
|
+
event_queue.put_nowait(workflow_started)
|
|
115
|
+
|
|
116
|
+
current_id: str | None = start
|
|
117
|
+
current_state = state
|
|
118
|
+
|
|
119
|
+
while current_id is not None:
|
|
120
|
+
if current_id not in self._nodes:
|
|
121
|
+
msg = f"Agent '{current_id}' is not registered in this AgentGraph."
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
|
|
124
|
+
node = self._nodes[current_id]
|
|
125
|
+
context = slice_state(current_state, node.context_keys)
|
|
126
|
+
|
|
127
|
+
async with self._sem:
|
|
128
|
+
patch = await node.fn(context)
|
|
129
|
+
|
|
130
|
+
old_value = current_state.model_dump()
|
|
131
|
+
current_state = apply_patch(current_state, patch)
|
|
132
|
+
new_value = current_state.model_dump()
|
|
133
|
+
|
|
134
|
+
patch_event = PatchApplied(
|
|
135
|
+
workflow_id=workflow_id,
|
|
136
|
+
agent_id=patch.agent_id,
|
|
137
|
+
type="patch_applied",
|
|
138
|
+
patch_id=patch.patch_id,
|
|
139
|
+
target=patch.target,
|
|
140
|
+
old_value=old_value,
|
|
141
|
+
new_value=new_value,
|
|
142
|
+
reason=patch.reason,
|
|
143
|
+
timestamp=patch.timestamp,
|
|
144
|
+
)
|
|
145
|
+
await self._store.append(patch_event)
|
|
146
|
+
if event_queue is not None:
|
|
147
|
+
event_queue.put_nowait(patch_event)
|
|
148
|
+
|
|
149
|
+
current_id = self._next_agent(node.agent_id, current_state)
|
|
150
|
+
|
|
151
|
+
workflow_completed = WorkflowCompleted(
|
|
152
|
+
workflow_id=workflow_id,
|
|
153
|
+
agent_id="system",
|
|
154
|
+
type="workflow_completed",
|
|
155
|
+
final_status=current_state.status,
|
|
156
|
+
)
|
|
157
|
+
await self._store.append(workflow_completed)
|
|
158
|
+
if event_queue is not None:
|
|
159
|
+
event_queue.put_nowait(workflow_completed)
|
|
160
|
+
|
|
161
|
+
return current_state
|
|
162
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from agentstate.core.patch import StatePatch
|
|
8
|
+
|
|
9
|
+
# Any async function that takes a context dict and returns a StatePatch
|
|
10
|
+
# is a valid agent. No inheritance required.
|
|
11
|
+
AgentFn = Callable[[dict[str,Any]], Awaitable["StatePatch"]]
|
|
12
|
+
|
|
13
|
+
# A function that takes the current state as a dict and returns True
|
|
14
|
+
# if this edge should be followed.
|
|
15
|
+
EdgeCondition = Callable[[dict[str, Any]], bool]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentstate_lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stateful coordination layer for multi-agent AI systems
|
|
5
|
+
Author-email: Tanveer Kaur <tanveerkaur1292@outlook.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agents,llm,multi-agent,orchestration,state
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: aiosqlite>=0.19
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Provides-Extra: api
|
|
13
|
+
Requires-Dist: fastapi>=0.100; extra == 'api'
|
|
14
|
+
Requires-Dist: uvicorn[standard]>=0.20; extra == 'api'
|
|
15
|
+
Provides-Extra: contrib
|
|
16
|
+
Provides-Extra: dashboard
|
|
17
|
+
Requires-Dist: rich>=13.0; extra == 'dashboard'
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: httpx>=0.24; extra == 'dev'
|
|
20
|
+
Requires-Dist: hypothesis>=6.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
25
|
+
Provides-Extra: otel
|
|
26
|
+
Requires-Dist: opentelemetry-api; extra == 'otel'
|
|
27
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc; extra == 'otel'
|
|
28
|
+
Requires-Dist: opentelemetry-sdk; extra == 'otel'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# agentstate
|
|
32
|
+
Building a stateful coordination layer for multi-agent AI systems.
|
|
33
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
agentstate/__init__.py,sha256=3YZ6uWZUa7E1L8xC4OrBUFJ2ndiRfGDUSEqIPra3D9c,1325
|
|
2
|
+
agentstate/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
agentstate/api/app.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
agentstate/api/auth.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
agentstate/api/cache.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
agentstate/api/routes.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
agentstate/api/streaming.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agentstate/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
agentstate/contrib/base_agent.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
agentstate/coordination/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
agentstate/coordination/conflicts.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
agentstate/coordination/invariants.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
agentstate/core/__init__.py,sha256=MzflZdUoctDKhZzpJbyZbjM9JLhQmQKpBFuXE8p2Mm4,617
|
|
14
|
+
agentstate/core/events.py,sha256=5dKXoS5CYd_PXWrZ65pwZfbPBg-VbtiGJQEsMJEkWHQ,1960
|
|
15
|
+
agentstate/core/patch.py,sha256=4d27aIdnekcmSY7MpMgVTMbqLpC_JK4A7qO5xT866Yw,1799
|
|
16
|
+
agentstate/core/state.py,sha256=FkafgxnVDJ2P2XtkUNjAPKkE9BZwM1pOn7ukTCY1VVs,1821
|
|
17
|
+
agentstate/memory/__init__.py,sha256=21GRbnmwoNmV--n9LJxti-weTo1mrBhuAwgR0qJBF6g,159
|
|
18
|
+
agentstate/memory/checkpoint.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
agentstate/memory/replay.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
agentstate/memory/store.py,sha256=omOIWwdccotsqFQTbMY1crfOBJQJX-ijY6MdE2EIZHo,4425
|
|
21
|
+
agentstate/observability/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
agentstate/observability/analysis.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
agentstate/observability/dashboard.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
agentstate/observability/tracing.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
agentstate/router/__init__.py,sha256=qROHk83A6XTM88J6EEWiSPwr6czaWGNuVMXOC0Mc-zo,290
|
|
26
|
+
agentstate/router/context.py,sha256=7XSPOPqpZgytyBIhsbLPHYLvYSaxzd2eHJUDoAAob7Y,751
|
|
27
|
+
agentstate/router/graph.py,sha256=S_m96lUwwuw2H30v8mH7Ja4CPYUkHpJL7ZkYboqk8fE,5342
|
|
28
|
+
agentstate/router/types.py,sha256=SkuV6l1kpjSn4QpdVD5gH1Ojp-Hsn7NKysF3Asp7ljM,536
|
|
29
|
+
agentstate_lib-0.1.0.dist-info/METADATA,sha256=qKTj7tG29p5A15FOnzz90AD1om02LeqqmiLN7aQESbs,1171
|
|
30
|
+
agentstate_lib-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
31
|
+
agentstate_lib-0.1.0.dist-info/licenses/LICENSE,sha256=zUb9KlZdCdZd8E5fO4a-ySskntrRYVApJRiDhk9J54w,1090
|
|
32
|
+
agentstate_lib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tanveer kaur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|