axeon-atrest 0.1.0__tar.gz
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.
- axeon_atrest-0.1.0/PKG-INFO +87 -0
- axeon_atrest-0.1.0/README.md +61 -0
- axeon_atrest-0.1.0/atrest/__init__.py +6 -0
- axeon_atrest-0.1.0/atrest/agent.py +68 -0
- axeon_atrest-0.1.0/atrest/bus.py +120 -0
- axeon_atrest-0.1.0/atrest/client.py +53 -0
- axeon_atrest-0.1.0/atrest/config.py +33 -0
- axeon_atrest-0.1.0/atrest/context.py +28 -0
- axeon_atrest-0.1.0/atrest/llm.py +16 -0
- axeon_atrest-0.1.0/atrest/qdrant.py +11 -0
- axeon_atrest-0.1.0/atrest/task_store.py +100 -0
- axeon_atrest-0.1.0/axeon_atrest.egg-info/PKG-INFO +87 -0
- axeon_atrest-0.1.0/axeon_atrest.egg-info/SOURCES.txt +22 -0
- axeon_atrest-0.1.0/axeon_atrest.egg-info/dependency_links.txt +1 -0
- axeon_atrest-0.1.0/axeon_atrest.egg-info/requires.txt +14 -0
- axeon_atrest-0.1.0/axeon_atrest.egg-info/top_level.txt +2 -0
- axeon_atrest-0.1.0/pyproject.toml +34 -0
- axeon_atrest-0.1.0/setup.cfg +4 -0
- axeon_atrest-0.1.0/shared/__init__.py +0 -0
- axeon_atrest-0.1.0/shared/agent_eval.py +79 -0
- axeon_atrest-0.1.0/shared/agent_memory.py +59 -0
- axeon_atrest-0.1.0/tests/test_fase1.py +133 -0
- axeon_atrest-0.1.0/tests/test_fase2.py +187 -0
- axeon_atrest-0.1.0/tests/test_fase4.py +118 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axeon-atrest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Task Runtime & State Transport — middleware for AI agent communication, memory, and task lifecycle
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: ai,agents,multi-agent,orchestration,middleware,llm
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: redis>=5.0
|
|
15
|
+
Requires-Dist: neo4j>=5.0
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: rich>=13.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: api
|
|
21
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
22
|
+
Requires-Dist: uvicorn>=0.22; extra == "api"
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == "all"
|
|
25
|
+
Requires-Dist: uvicorn>=0.22; extra == "all"
|
|
26
|
+
|
|
27
|
+
# AT Rest — Agent Task Runtime & State Transport
|
|
28
|
+
|
|
29
|
+
Middleware untuk agent communication, memory, dan task lifecycle.
|
|
30
|
+
|
|
31
|
+
4 agent built-in (planner, researcher, reviewer, mentor) via Redis Streams bus.
|
|
32
|
+
Neo4j task persistence. FastAPI REST API server. Qdrant + Neo4j knowledge retrieval.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install atrest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from atrest import Agent, generate, search
|
|
42
|
+
|
|
43
|
+
class MyAgent(Agent):
|
|
44
|
+
channel = "researcher"
|
|
45
|
+
|
|
46
|
+
def handle_request(self, msg):
|
|
47
|
+
goal = msg["payload"]["goal"]
|
|
48
|
+
results = search(goal, limit=3)
|
|
49
|
+
summary = generate(f"Summarize: {goal}\n\nContext: {results}")
|
|
50
|
+
return {"summary": summary, "sources": []}
|
|
51
|
+
|
|
52
|
+
MyAgent().run()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Available Imports
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from atrest import Agent, ATRestClient, AgentBus, TaskStore, generate, embed, search
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `Agent` — base class for custom agents
|
|
62
|
+
- `ATRestClient` — HTTP client for AT Rest API
|
|
63
|
+
- `AgentBus` — Redis Streams pub/sub
|
|
64
|
+
- `TaskStore` — Neo4j AgentTask persistence
|
|
65
|
+
- `generate()` — Ollama LLM call
|
|
66
|
+
- `embed()` — Ollama embedding
|
|
67
|
+
- `search()` — Qdrant vector search
|
|
68
|
+
|
|
69
|
+
## Optional: API Server
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install atrest[api]
|
|
73
|
+
python -m api.server
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
User → REST API / Agent SDK → Agent Bus (Redis Streams)
|
|
80
|
+
→ Planner → Researcher / Reviewer / Mentor
|
|
81
|
+
→ Task Store (Neo4j) → Broadcast
|
|
82
|
+
→ Knowledge (Qdrant + Neo4j)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AT Rest — Agent Task Runtime & State Transport
|
|
2
|
+
|
|
3
|
+
Middleware untuk agent communication, memory, dan task lifecycle.
|
|
4
|
+
|
|
5
|
+
4 agent built-in (planner, researcher, reviewer, mentor) via Redis Streams bus.
|
|
6
|
+
Neo4j task persistence. FastAPI REST API server. Qdrant + Neo4j knowledge retrieval.
|
|
7
|
+
|
|
8
|
+
## Quick Start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install atrest
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from atrest import Agent, generate, search
|
|
16
|
+
|
|
17
|
+
class MyAgent(Agent):
|
|
18
|
+
channel = "researcher"
|
|
19
|
+
|
|
20
|
+
def handle_request(self, msg):
|
|
21
|
+
goal = msg["payload"]["goal"]
|
|
22
|
+
results = search(goal, limit=3)
|
|
23
|
+
summary = generate(f"Summarize: {goal}\n\nContext: {results}")
|
|
24
|
+
return {"summary": summary, "sources": []}
|
|
25
|
+
|
|
26
|
+
MyAgent().run()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Available Imports
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from atrest import Agent, ATRestClient, AgentBus, TaskStore, generate, embed, search
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `Agent` — base class for custom agents
|
|
36
|
+
- `ATRestClient` — HTTP client for AT Rest API
|
|
37
|
+
- `AgentBus` — Redis Streams pub/sub
|
|
38
|
+
- `TaskStore` — Neo4j AgentTask persistence
|
|
39
|
+
- `generate()` — Ollama LLM call
|
|
40
|
+
- `embed()` — Ollama embedding
|
|
41
|
+
- `search()` — Qdrant vector search
|
|
42
|
+
|
|
43
|
+
## Optional: API Server
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install atrest[api]
|
|
47
|
+
python -m api.server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Architecture
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
User → REST API / Agent SDK → Agent Bus (Redis Streams)
|
|
54
|
+
→ Planner → Researcher / Reviewer / Mentor
|
|
55
|
+
→ Task Store (Neo4j) → Broadcast
|
|
56
|
+
→ Knowledge (Qdrant + Neo4j)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""AT Rest — Base Agent class. Extend to create custom agents."""
|
|
2
|
+
import uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from atrest.bus import AgentBus
|
|
6
|
+
from atrest.task_store import TaskStore
|
|
7
|
+
from shared.agent_memory import log_finding, log_decision
|
|
8
|
+
|
|
9
|
+
class Agent:
|
|
10
|
+
"""Base class for AT Rest agents.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
class MyAgent(Agent):
|
|
14
|
+
channel = "my_agent"
|
|
15
|
+
|
|
16
|
+
def handle_request(self, msg):
|
|
17
|
+
goal = msg["payload"]["goal"]
|
|
18
|
+
# ... process ...
|
|
19
|
+
return {"summary": "done"}
|
|
20
|
+
|
|
21
|
+
MyAgent().run()
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
channel = None # override in subclass — e.g. "researcher", "reviewer"
|
|
25
|
+
|
|
26
|
+
def __init__(self, agent_id=None):
|
|
27
|
+
self.agent_id = agent_id or self.__class__.__name__.lower()
|
|
28
|
+
self.bus = AgentBus(agent_id=self.agent_id)
|
|
29
|
+
self.store = TaskStore()
|
|
30
|
+
|
|
31
|
+
def handle_request(self, msg):
|
|
32
|
+
"""Override this. Return dict with at least 'summary' key."""
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def _process(self, msg):
|
|
36
|
+
goal = msg["payload"].get("goal", "")
|
|
37
|
+
conversation_id = msg["conversation_id"]
|
|
38
|
+
task_id = msg.get("task_id", "")
|
|
39
|
+
log_finding(self.agent_id, f"Processing: {goal[:60]}", f"task={task_id[:8]}")
|
|
40
|
+
|
|
41
|
+
if task_id:
|
|
42
|
+
self.store.update_status(task_id, "in_progress")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
result = self.handle_request(msg)
|
|
46
|
+
if task_id:
|
|
47
|
+
self.store.update_status(task_id, "done")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
log_finding(self.agent_id, f"Error: {e}")
|
|
50
|
+
result = {"summary": f"Error: {e}"}
|
|
51
|
+
if task_id:
|
|
52
|
+
self.store.update_status(task_id, "blocked")
|
|
53
|
+
|
|
54
|
+
self.bus.publish("planner", {
|
|
55
|
+
"from": self.agent_id, "to": "planner", "intent": "RESPONSE",
|
|
56
|
+
"conversation_id": conversation_id, "task_id": task_id,
|
|
57
|
+
"payload": result,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
def run(self):
|
|
61
|
+
if not self.channel:
|
|
62
|
+
raise ValueError("Agent.channel must be set")
|
|
63
|
+
print(f"[{self.agent_id}] Starting. Subscribing to bus:{self.channel}...", flush=True)
|
|
64
|
+
for msg in self.bus.subscribe(self.channel):
|
|
65
|
+
if msg is None:
|
|
66
|
+
continue
|
|
67
|
+
if msg.get("intent") == "REQUEST":
|
|
68
|
+
self._process(msg)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""AT Rest — Redis Streams AgentBus wrapper."""
|
|
2
|
+
import json, uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
from atrest.config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_DB
|
|
8
|
+
|
|
9
|
+
STREAMS = {
|
|
10
|
+
"planner": "bus:planner",
|
|
11
|
+
"researcher": "bus:researcher",
|
|
12
|
+
"reviewer": "bus:reviewer",
|
|
13
|
+
"mentor": "bus:mentor",
|
|
14
|
+
"broadcast": "bus:broadcast",
|
|
15
|
+
"conflict": "bus:conflict",
|
|
16
|
+
"dead_letter": "bus:dead_letter",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class AgentBus:
|
|
20
|
+
def __init__(self, agent_id="unknown"):
|
|
21
|
+
self.agent_id = agent_id
|
|
22
|
+
self.redis = redis.Redis(
|
|
23
|
+
host=REDIS_HOST, port=REDIS_PORT,
|
|
24
|
+
password=REDIS_PASSWORD, db=REDIS_DB,
|
|
25
|
+
decode_responses=True,
|
|
26
|
+
)
|
|
27
|
+
self.redis.ping()
|
|
28
|
+
|
|
29
|
+
# ── Stream helpers ────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def _stream(self, channel):
|
|
32
|
+
if channel in STREAMS:
|
|
33
|
+
return STREAMS[channel]
|
|
34
|
+
if isinstance(channel, str) and channel.startswith("bus:"):
|
|
35
|
+
return channel
|
|
36
|
+
return f"bus:{channel}"
|
|
37
|
+
|
|
38
|
+
# ── Envelope ──────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _envelope(self, msg):
|
|
41
|
+
return {
|
|
42
|
+
"msg_id": str(uuid.uuid4()),
|
|
43
|
+
"from": msg.get("from", self.agent_id),
|
|
44
|
+
"to": msg.get("to", ""),
|
|
45
|
+
"intent": msg.get("intent", "REQUEST"),
|
|
46
|
+
"task_id": msg.get("task_id") or "",
|
|
47
|
+
"payload": json.dumps(msg.get("payload", {}), ensure_ascii=False),
|
|
48
|
+
"conversation_id": msg.get("conversation_id") or str(uuid.uuid4()),
|
|
49
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
50
|
+
"ttl": str(msg.get("ttl", 30)),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def _decode(self, raw):
|
|
54
|
+
env = dict(raw)
|
|
55
|
+
if "payload" in env:
|
|
56
|
+
try:
|
|
57
|
+
env["payload"] = json.loads(env["payload"])
|
|
58
|
+
except (json.JSONDecodeError, TypeError):
|
|
59
|
+
env["payload"] = {}
|
|
60
|
+
return env
|
|
61
|
+
|
|
62
|
+
# ── Publish ───────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def publish(self, channel, message):
|
|
65
|
+
stream = self._stream(channel)
|
|
66
|
+
env = self._envelope(message)
|
|
67
|
+
return self.redis.xadd(stream, env, maxlen=10000)
|
|
68
|
+
|
|
69
|
+
# ── Subscribe (consumer group) ───────────────────────────────
|
|
70
|
+
|
|
71
|
+
def subscribe(self, channel, group=None, consumer=None):
|
|
72
|
+
stream = self._stream(channel)
|
|
73
|
+
group_name = group or f"atrest-{stream}"
|
|
74
|
+
consumer_id = consumer or f"{self.agent_id}-{uuid.uuid4().hex[:8]}"
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
self.redis.xgroup_create(stream, group_name, id="0", mkstream=True)
|
|
78
|
+
except redis.exceptions.ResponseError as e:
|
|
79
|
+
if "BUSYGROUP" not in str(e):
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
results = self.redis.xreadgroup(
|
|
85
|
+
group_name, consumer_id, {stream: ">"},
|
|
86
|
+
block=5000, count=1,
|
|
87
|
+
)
|
|
88
|
+
except redis.exceptions.ConnectionError:
|
|
89
|
+
yield None
|
|
90
|
+
continue
|
|
91
|
+
except redis.exceptions.TimeoutError:
|
|
92
|
+
continue
|
|
93
|
+
except redis.exceptions.ResponseError as e:
|
|
94
|
+
# NOGROUP — stream or consumer group was deleted, recreate
|
|
95
|
+
if "NOGROUP" in str(e):
|
|
96
|
+
try:
|
|
97
|
+
self.redis.xgroup_create(stream, group_name, id="0", mkstream=True)
|
|
98
|
+
except redis.exceptions.ResponseError:
|
|
99
|
+
pass
|
|
100
|
+
continue
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
if results:
|
|
104
|
+
for stream_name, messages in results:
|
|
105
|
+
for msg_id, msg_data in messages:
|
|
106
|
+
yield self._decode(msg_data)
|
|
107
|
+
self.redis.xack(stream, group_name, msg_id)
|
|
108
|
+
else:
|
|
109
|
+
yield None # tick — allows caller to process timeouts
|
|
110
|
+
|
|
111
|
+
# ── Dead letter ──────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def dead_letter(self, original_msg, reason):
|
|
114
|
+
self.publish("dead_letter", {
|
|
115
|
+
"intent": "DEAD_LETTER",
|
|
116
|
+
"payload": {
|
|
117
|
+
"original_msg_id": original_msg.get("msg_id", ""),
|
|
118
|
+
"reason": str(reason),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""AT Rest — API Client. Wraps REST calls to AT Rest API server."""
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
class ATRestClient:
|
|
5
|
+
"""Minimal client for AT Rest API.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
client = ATRestClient("http://localhost:8080")
|
|
9
|
+
resp = client.research("Redis configuration")
|
|
10
|
+
print(resp["summary"])
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, base_url="http://localhost:8080", timeout=30):
|
|
14
|
+
self.base_url = base_url.rstrip("/")
|
|
15
|
+
self.timeout = timeout
|
|
16
|
+
|
|
17
|
+
def _post(self, path, data):
|
|
18
|
+
r = httpx.post(f"{self.base_url}{path}", json=data, timeout=self.timeout)
|
|
19
|
+
r.raise_for_status()
|
|
20
|
+
return r.json()
|
|
21
|
+
|
|
22
|
+
def _get(self, path, params=None):
|
|
23
|
+
r = httpx.get(f"{self.base_url}{path}", params=params, timeout=self.timeout)
|
|
24
|
+
r.raise_for_status()
|
|
25
|
+
return r.json()
|
|
26
|
+
|
|
27
|
+
def health(self):
|
|
28
|
+
return self._get("/v1/health")
|
|
29
|
+
|
|
30
|
+
def create_task(self, goal, hint="researcher"):
|
|
31
|
+
return self._post("/v1/tasks", {"goal": goal, "hint": hint})
|
|
32
|
+
|
|
33
|
+
def get_task(self, task_id):
|
|
34
|
+
return self._get(f"/v1/tasks/{task_id}")
|
|
35
|
+
|
|
36
|
+
def list_tasks(self, status=None):
|
|
37
|
+
params = {"status": status} if status else {}
|
|
38
|
+
return self._get("/v1/tasks", params=params)
|
|
39
|
+
|
|
40
|
+
def task_lineage(self, task_id):
|
|
41
|
+
return self._get(f"/v1/tasks/{task_id}/lineage")
|
|
42
|
+
|
|
43
|
+
def research(self, query, limit=3):
|
|
44
|
+
return self._post("/v1/research", {"query": query, "limit": limit})
|
|
45
|
+
|
|
46
|
+
def graph_explore(self, query, limit=5):
|
|
47
|
+
return self._get("/v1/graph/explore", params={"query": query, "limit": limit})
|
|
48
|
+
|
|
49
|
+
def infra_dependencies(self):
|
|
50
|
+
return self._get("/v1/graph/dependencies")
|
|
51
|
+
|
|
52
|
+
def review(self, path):
|
|
53
|
+
return self._post("/v1/review", {"path": path})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""AT Rest — centralized configuration from environment."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
# Redis
|
|
9
|
+
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
|
10
|
+
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
|
11
|
+
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") or None
|
|
12
|
+
REDIS_DB = int(os.getenv("REDIS_DB", "0"))
|
|
13
|
+
|
|
14
|
+
# Neo4j
|
|
15
|
+
NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
|
|
16
|
+
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
|
|
17
|
+
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "")
|
|
18
|
+
|
|
19
|
+
# Qdrant
|
|
20
|
+
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
|
|
21
|
+
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
|
|
22
|
+
QDRANT_URL = f"http://{QDRANT_HOST}:{QDRANT_PORT}"
|
|
23
|
+
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "knowledge")
|
|
24
|
+
|
|
25
|
+
# Ollama
|
|
26
|
+
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
27
|
+
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:1.5b")
|
|
28
|
+
EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
|
|
29
|
+
|
|
30
|
+
# AT Rest
|
|
31
|
+
ATREST_LOG_DIR = Path(os.getenv("ATREST_LOG_DIR", "./logs"))
|
|
32
|
+
ATREST_ENV = os.getenv("ATREST_ENV", "development")
|
|
33
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""AT Rest — thin cached wrapper for context-builder.py from memory/."""
|
|
2
|
+
import os, sys, importlib.util
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_MEMORY_ROOT = Path(os.getenv("ATREST_MEMORY_PATH", os.path.expanduser("~/memory")))
|
|
6
|
+
_CACHE = {}
|
|
7
|
+
|
|
8
|
+
def _load_cb():
|
|
9
|
+
if "cb" in _CACHE:
|
|
10
|
+
return _CACHE["cb"]
|
|
11
|
+
path = _MEMORY_ROOT / "context-builder.py"
|
|
12
|
+
if not path.exists():
|
|
13
|
+
return None
|
|
14
|
+
spec = importlib.util.spec_from_file_location("context_builder", str(path))
|
|
15
|
+
mod = importlib.util.module_from_spec(spec)
|
|
16
|
+
sys.path.insert(0, str(_MEMORY_ROOT))
|
|
17
|
+
try:
|
|
18
|
+
spec.loader.exec_module(mod)
|
|
19
|
+
except Exception:
|
|
20
|
+
return None
|
|
21
|
+
_CACHE["cb"] = mod
|
|
22
|
+
return mod
|
|
23
|
+
|
|
24
|
+
def infra_traverse(query):
|
|
25
|
+
mod = _load_cb()
|
|
26
|
+
if mod is None:
|
|
27
|
+
return {}
|
|
28
|
+
return mod.infra_traverse(query)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AT Rest — shared Ollama helpers (generate + embed)."""
|
|
2
|
+
import httpx
|
|
3
|
+
from atrest.config import OLLAMA_BASE_URL, OLLAMA_MODEL, EMBED_MODEL
|
|
4
|
+
|
|
5
|
+
def generate(prompt, max_tokens=400, temperature=0.1, timeout=60):
|
|
6
|
+
r = httpx.post(f"{OLLAMA_BASE_URL}/api/generate", json={
|
|
7
|
+
"model": OLLAMA_MODEL, "prompt": prompt,
|
|
8
|
+
"stream": False, "options": {"temperature": temperature, "num_predict": max_tokens},
|
|
9
|
+
}, timeout=timeout)
|
|
10
|
+
r.raise_for_status()
|
|
11
|
+
return r.json()["response"].strip()
|
|
12
|
+
|
|
13
|
+
def embed(text, timeout=30):
|
|
14
|
+
r = httpx.post(f"{OLLAMA_BASE_URL}/api/embeddings",
|
|
15
|
+
json={"model": EMBED_MODEL, "prompt": text}, timeout=timeout)
|
|
16
|
+
return r.json()["embedding"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""AT Rest — shared Qdrant search."""
|
|
2
|
+
import httpx
|
|
3
|
+
from atrest.config import QDRANT_URL, QDRANT_COLLECTION
|
|
4
|
+
from atrest.llm import embed
|
|
5
|
+
|
|
6
|
+
def search(query, limit=3):
|
|
7
|
+
vec = embed(query)
|
|
8
|
+
r = httpx.post(f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points/search", json={
|
|
9
|
+
"vector": vec, "limit": limit, "with_payload": True,
|
|
10
|
+
}, timeout=10)
|
|
11
|
+
return r.json().get("result", [])
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""AT Rest — Neo4j AgentTask persistence."""
|
|
2
|
+
import time, uuid
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from neo4j import GraphDatabase
|
|
6
|
+
from neo4j.exceptions import TransientError
|
|
7
|
+
|
|
8
|
+
from atrest.config import NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD
|
|
9
|
+
|
|
10
|
+
class TaskStore:
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
|
|
13
|
+
self._ensure_constraint()
|
|
14
|
+
|
|
15
|
+
def _ensure_constraint(self):
|
|
16
|
+
for attempt in range(3):
|
|
17
|
+
try:
|
|
18
|
+
with self.driver.session() as s:
|
|
19
|
+
s.run(
|
|
20
|
+
"CREATE CONSTRAINT IF NOT EXISTS FOR (t:AgentTask) REQUIRE t.id IS UNIQUE"
|
|
21
|
+
)
|
|
22
|
+
return
|
|
23
|
+
except TransientError:
|
|
24
|
+
if attempt < 2:
|
|
25
|
+
time.sleep(0.5 * (attempt + 1))
|
|
26
|
+
continue
|
|
27
|
+
raise
|
|
28
|
+
|
|
29
|
+
def create_task(self, goal, assigned_to, created_by, parent_id=None, parent_goal=None):
|
|
30
|
+
task_id = str(uuid.uuid4())
|
|
31
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
32
|
+
with self.driver.session() as s:
|
|
33
|
+
s.run(
|
|
34
|
+
"""CREATE (t:AgentTask {id: $id, goal: $goal, parent_goal: $parent_goal, status: 'pending',
|
|
35
|
+
assigned_to: $assigned_to, created_by: $created_by,
|
|
36
|
+
parent_task_id: $parent_id,
|
|
37
|
+
created_at: $now, updated_at: $now})""",
|
|
38
|
+
id=task_id, goal=goal, parent_goal=parent_goal, assigned_to=assigned_to,
|
|
39
|
+
created_by=created_by, parent_id=parent_id, now=now,
|
|
40
|
+
)
|
|
41
|
+
return task_id
|
|
42
|
+
|
|
43
|
+
def update_status(self, task_id, status):
|
|
44
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
45
|
+
with self.driver.session() as s:
|
|
46
|
+
s.run(
|
|
47
|
+
"MATCH (t:AgentTask {id: $id}) SET t.status = $status, t.updated_at = $now",
|
|
48
|
+
id=task_id, status=status, now=now,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get_task(self, task_id):
|
|
52
|
+
with self.driver.session() as s:
|
|
53
|
+
row = s.run(
|
|
54
|
+
"MATCH (t:AgentTask {id: $id}) RETURN t.id AS id, t.goal AS goal, "
|
|
55
|
+
"t.status AS status, t.assigned_to AS assigned_to, "
|
|
56
|
+
"t.created_by AS created_by, t.parent_task_id AS parent_task_id, "
|
|
57
|
+
"t.created_at AS created_at, t.updated_at AS updated_at",
|
|
58
|
+
id=task_id,
|
|
59
|
+
).single()
|
|
60
|
+
return dict(row) if row else None
|
|
61
|
+
|
|
62
|
+
def find_duplicate(self, goal):
|
|
63
|
+
with self.driver.session() as s:
|
|
64
|
+
row = s.run(
|
|
65
|
+
"MATCH (t:AgentTask {goal: $goal, status: 'done'}) "
|
|
66
|
+
"RETURN t.id AS id LIMIT 1",
|
|
67
|
+
goal=goal,
|
|
68
|
+
).single()
|
|
69
|
+
return row["id"] if row else None
|
|
70
|
+
|
|
71
|
+
def find_duplicate_by_parent_goal(self, parent_goal):
|
|
72
|
+
with self.driver.session() as s:
|
|
73
|
+
row = s.run(
|
|
74
|
+
"MATCH (t:AgentTask {parent_goal: $pg, status: 'done'}) "
|
|
75
|
+
"RETURN t.id AS id LIMIT 1",
|
|
76
|
+
pg=parent_goal,
|
|
77
|
+
).single()
|
|
78
|
+
return row["id"] if row else None
|
|
79
|
+
|
|
80
|
+
def link_reference(self, task_id, node_id, node_label):
|
|
81
|
+
with self.driver.session() as s:
|
|
82
|
+
s.run(
|
|
83
|
+
f"MATCH (t:AgentTask {{id: $task_id}}) "
|
|
84
|
+
f"MATCH (n:{node_label} {{id: $node_id}}) "
|
|
85
|
+
f"MERGE (t)-[:REFERENCES]->(n)",
|
|
86
|
+
task_id=task_id, node_id=node_id,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get_task_by_goal(self, goal):
|
|
90
|
+
with self.driver.session() as s:
|
|
91
|
+
rows = list(s.run(
|
|
92
|
+
"MATCH (t:AgentTask) WHERE t.goal = $goal OR t.parent_goal = $goal "
|
|
93
|
+
"RETURN t.id AS id, t.status AS status "
|
|
94
|
+
"ORDER BY t.created_at ASC",
|
|
95
|
+
goal=goal,
|
|
96
|
+
))
|
|
97
|
+
return [dict(r) for r in rows]
|
|
98
|
+
|
|
99
|
+
def close(self):
|
|
100
|
+
self.driver.close()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axeon-atrest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Task Runtime & State Transport — middleware for AI agent communication, memory, and task lifecycle
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: ai,agents,multi-agent,orchestration,middleware,llm
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: redis>=5.0
|
|
15
|
+
Requires-Dist: neo4j>=5.0
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: rich>=13.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: api
|
|
21
|
+
Requires-Dist: fastapi>=0.100; extra == "api"
|
|
22
|
+
Requires-Dist: uvicorn>=0.22; extra == "api"
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == "all"
|
|
25
|
+
Requires-Dist: uvicorn>=0.22; extra == "all"
|
|
26
|
+
|
|
27
|
+
# AT Rest — Agent Task Runtime & State Transport
|
|
28
|
+
|
|
29
|
+
Middleware untuk agent communication, memory, dan task lifecycle.
|
|
30
|
+
|
|
31
|
+
4 agent built-in (planner, researcher, reviewer, mentor) via Redis Streams bus.
|
|
32
|
+
Neo4j task persistence. FastAPI REST API server. Qdrant + Neo4j knowledge retrieval.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install atrest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from atrest import Agent, generate, search
|
|
42
|
+
|
|
43
|
+
class MyAgent(Agent):
|
|
44
|
+
channel = "researcher"
|
|
45
|
+
|
|
46
|
+
def handle_request(self, msg):
|
|
47
|
+
goal = msg["payload"]["goal"]
|
|
48
|
+
results = search(goal, limit=3)
|
|
49
|
+
summary = generate(f"Summarize: {goal}\n\nContext: {results}")
|
|
50
|
+
return {"summary": summary, "sources": []}
|
|
51
|
+
|
|
52
|
+
MyAgent().run()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Available Imports
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from atrest import Agent, ATRestClient, AgentBus, TaskStore, generate, embed, search
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `Agent` — base class for custom agents
|
|
62
|
+
- `ATRestClient` — HTTP client for AT Rest API
|
|
63
|
+
- `AgentBus` — Redis Streams pub/sub
|
|
64
|
+
- `TaskStore` — Neo4j AgentTask persistence
|
|
65
|
+
- `generate()` — Ollama LLM call
|
|
66
|
+
- `embed()` — Ollama embedding
|
|
67
|
+
- `search()` — Qdrant vector search
|
|
68
|
+
|
|
69
|
+
## Optional: API Server
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install atrest[api]
|
|
73
|
+
python -m api.server
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Architecture
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
User → REST API / Agent SDK → Agent Bus (Redis Streams)
|
|
80
|
+
→ Planner → Researcher / Reviewer / Mentor
|
|
81
|
+
→ Task Store (Neo4j) → Broadcast
|
|
82
|
+
→ Knowledge (Qdrant + Neo4j)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|