hyperforge 1.0.0.post19__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.
- hyperforge/__init__.py +16 -0
- hyperforge/agent.py +81 -0
- hyperforge/api/__init__.py +20 -0
- hyperforge/api/app.py +155 -0
- hyperforge/api/authentication.py +271 -0
- hyperforge/api/commands.py +33 -0
- hyperforge/api/internal/__init__.py +4 -0
- hyperforge/api/internal/inspect.py +30 -0
- hyperforge/api/internal/router.py +3 -0
- hyperforge/api/logging.py +18 -0
- hyperforge/api/models.py +129 -0
- hyperforge/api/session.py +197 -0
- hyperforge/api/settings.py +38 -0
- hyperforge/api/utils.py +354 -0
- hyperforge/api/v1/__init__.py +23 -0
- hyperforge/api/v1/agents.py +531 -0
- hyperforge/api/v1/interaction.py +430 -0
- hyperforge/api/v1/mcp_content.py +311 -0
- hyperforge/api/v1/mcp_interaction.py +322 -0
- hyperforge/api/v1/oauth.py +60 -0
- hyperforge/api/v1/prompt.py +129 -0
- hyperforge/api/v1/router.py +3 -0
- hyperforge/api/v1/schema.py +56 -0
- hyperforge/api/v1/session.py +182 -0
- hyperforge/api/v1/utils.py +12 -0
- hyperforge/api/v1/workflows.py +643 -0
- hyperforge/arag.py +28 -0
- hyperforge/broker/__init__.py +52 -0
- hyperforge/broker/local.py +116 -0
- hyperforge/broker/redis.py +161 -0
- hyperforge/configure.py +571 -0
- hyperforge/context/__init__.py +0 -0
- hyperforge/context/agent.py +377 -0
- hyperforge/context/config.py +103 -0
- hyperforge/database.py +3 -0
- hyperforge/db/__init__.py +6 -0
- hyperforge/db/agents.py +1521 -0
- hyperforge/db/encryption.py +91 -0
- hyperforge/db/exceptions.py +26 -0
- hyperforge/db/settings.py +16 -0
- hyperforge/db/workflow_cleanup.py +69 -0
- hyperforge/definition.py +13 -0
- hyperforge/driver.py +31 -0
- hyperforge/dummy.py +28 -0
- hyperforge/engine.py +189 -0
- hyperforge/exceptions.py +14 -0
- hyperforge/feature_flag.py +105 -0
- hyperforge/fixtures.py +602 -0
- hyperforge/interaction.py +116 -0
- hyperforge/llm.py +75 -0
- hyperforge/manager.py +432 -0
- hyperforge/memory/__init__.py +5 -0
- hyperforge/memory/memory.py +974 -0
- hyperforge/minimal_fixtures.py +75 -0
- hyperforge/models.py +336 -0
- hyperforge/nua.py +336 -0
- hyperforge/openapi.py +63 -0
- hyperforge/prompts.py +188 -0
- hyperforge/pubsub.py +90 -0
- hyperforge/py.typed +0 -0
- hyperforge/redis_utils.py +82 -0
- hyperforge/retrieval/__init__.py +0 -0
- hyperforge/retrieval/agent.py +169 -0
- hyperforge/retrieval/config.py +94 -0
- hyperforge/server/__init__.py +5 -0
- hyperforge/server/cache.py +131 -0
- hyperforge/server/run.py +109 -0
- hyperforge/server/sandbox.py +60 -0
- hyperforge/server/session.py +421 -0
- hyperforge/server/settings.py +47 -0
- hyperforge/server/utils.py +57 -0
- hyperforge/server/web.py +31 -0
- hyperforge/settings.py +18 -0
- hyperforge/standalone/__init__.py +5 -0
- hyperforge/standalone/agent.py +189 -0
- hyperforge/standalone/app.py +264 -0
- hyperforge/standalone/config.py +137 -0
- hyperforge/standalone/const.py +1 -0
- hyperforge/standalone/run.py +60 -0
- hyperforge/standalone/settings.py +133 -0
- hyperforge/standalone/ui_router.py +241 -0
- hyperforge/trace.py +42 -0
- hyperforge/utils/__init__.py +112 -0
- hyperforge/utils/http.py +48 -0
- hyperforge/workflows.py +44 -0
- hyperforge-1.0.0.post19.dist-info/METADATA +95 -0
- hyperforge-1.0.0.post19.dist-info/RECORD +90 -0
- hyperforge-1.0.0.post19.dist-info/WHEEL +5 -0
- hyperforge-1.0.0.post19.dist-info/entry_points.txt +8 -0
- hyperforge-1.0.0.post19.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
|
|
3
|
+
from redis.asyncio.cluster import RedisCluster
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ManualStreamKeysRedisCluster(RedisCluster):
|
|
7
|
+
"""When connecting to a cluster, we need to know the key of each command to route it to the correct node. Stream functions (XREADGROUP, XREAD, etc) have a complicated syntax and the client does not extract the key, rather it sends the query to the server for parsing. If the server is down, this fails and the library does not have retries/reconnects for this code path. This can cause the client to get stuck and never reconnect. Since we know the keys, we can do this calculation ourselves to workaround this problem."""
|
|
8
|
+
|
|
9
|
+
def get_node_id_from_key(self, key: str):
|
|
10
|
+
keyslot = self.keyslot(key)
|
|
11
|
+
return self.nodes_manager.get_node_from_slot(keyslot)
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_url(cls, url, **kwargs):
|
|
15
|
+
return super().from_url(
|
|
16
|
+
url=url,
|
|
17
|
+
**kwargs,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
async def xreadgroup(
|
|
21
|
+
self,
|
|
22
|
+
groupname: str,
|
|
23
|
+
consumername: str,
|
|
24
|
+
streams: Dict[str, str],
|
|
25
|
+
block: int,
|
|
26
|
+
count: int,
|
|
27
|
+
noack: bool,
|
|
28
|
+
):
|
|
29
|
+
# Get the node from the first stream key
|
|
30
|
+
if len(streams) != 1:
|
|
31
|
+
raise ValueError("Only one stream key is supported")
|
|
32
|
+
node = self.get_node_from_key(list(streams.keys())[0])
|
|
33
|
+
cmd_args = [
|
|
34
|
+
"XREADGROUP",
|
|
35
|
+
"GROUP",
|
|
36
|
+
groupname,
|
|
37
|
+
consumername,
|
|
38
|
+
]
|
|
39
|
+
if noack:
|
|
40
|
+
cmd_args.append("NOACK")
|
|
41
|
+
cmd_args.extend(
|
|
42
|
+
[
|
|
43
|
+
"BLOCK",
|
|
44
|
+
str(block),
|
|
45
|
+
"COUNT",
|
|
46
|
+
str(count),
|
|
47
|
+
"STREAMS",
|
|
48
|
+
*streams.keys(),
|
|
49
|
+
*streams.values(),
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return await self.execute_command(*cmd_args, target_nodes=[node])
|
|
54
|
+
|
|
55
|
+
async def xread(
|
|
56
|
+
self,
|
|
57
|
+
streams: Dict[str, str],
|
|
58
|
+
block: int,
|
|
59
|
+
count: Optional[int] = None,
|
|
60
|
+
):
|
|
61
|
+
# Get the node from the first stream key
|
|
62
|
+
# Get the node from the first stream key
|
|
63
|
+
if len(streams) != 1:
|
|
64
|
+
raise ValueError("Only one stream key is supported")
|
|
65
|
+
node = self.get_node_from_key(list(streams.keys())[0])
|
|
66
|
+
|
|
67
|
+
cmd_args = [
|
|
68
|
+
"XREAD",
|
|
69
|
+
"BLOCK",
|
|
70
|
+
str(block),
|
|
71
|
+
]
|
|
72
|
+
if count is not None:
|
|
73
|
+
cmd_args.extend(["COUNT", str(count)])
|
|
74
|
+
|
|
75
|
+
cmd_args.extend(
|
|
76
|
+
[
|
|
77
|
+
"STREAMS",
|
|
78
|
+
*streams.keys(),
|
|
79
|
+
*streams.values(),
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
return await self.execute_command(*cmd_args, target_nodes=[node])
|
|
File without changes
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Callable, List, Literal, Optional
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from sentry_sdk import capture_exception
|
|
7
|
+
|
|
8
|
+
from hyperforge import logger
|
|
9
|
+
from hyperforge.agent import Agent
|
|
10
|
+
from hyperforge.configure import get_agent_klass
|
|
11
|
+
from hyperforge.context.agent import ContextAgent
|
|
12
|
+
from hyperforge.manager import Manager
|
|
13
|
+
from hyperforge.memory.memory import QuestionMemory
|
|
14
|
+
from hyperforge.retrieval.config import RetrievalAgentConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_stage_error(stage_name: str):
|
|
18
|
+
"""Decorator to handle errors in retrieval agent stages"""
|
|
19
|
+
|
|
20
|
+
def decorator(func: Callable):
|
|
21
|
+
@wraps(func)
|
|
22
|
+
async def wrapper(self, memory: QuestionMemory, *args, **kwargs):
|
|
23
|
+
try:
|
|
24
|
+
return await func(self, memory, *args, **kwargs)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
capture_exception(e)
|
|
27
|
+
logger.exception(f"Error in {stage_name}")
|
|
28
|
+
memory.final_answer = f"Error in {stage_name}"
|
|
29
|
+
raise # Re-raise to stop execution
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RetrievalAgent(Agent):
|
|
37
|
+
module: Literal["retrieval"] = "retrieval"
|
|
38
|
+
debug: bool = False
|
|
39
|
+
preprocess: Optional[list[Agent]] = None
|
|
40
|
+
context: Optional[list[ContextAgent]] = None
|
|
41
|
+
generation: Optional[list[Agent]] = None
|
|
42
|
+
postprocess: Optional[list[Agent]] = None
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
debug: bool = False,
|
|
47
|
+
preprocess: Optional[list[Agent]] = None,
|
|
48
|
+
context: Optional[list[ContextAgent]] = None,
|
|
49
|
+
generation: Optional[list[Agent]] = None,
|
|
50
|
+
postprocess: Optional[list[Agent]] = None,
|
|
51
|
+
):
|
|
52
|
+
self.debug = debug
|
|
53
|
+
self.preprocess = preprocess or []
|
|
54
|
+
self.context = context or []
|
|
55
|
+
self.generation = generation or []
|
|
56
|
+
self.postprocess = postprocess or []
|
|
57
|
+
|
|
58
|
+
async def inner_from_config(self, config: object, agent_id: object = None) -> None: # type: ignore[override]
|
|
59
|
+
"""No-op: RetrievalAgent is constructed via from_config_class, not from_config."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def from_config_class(cls, config: RetrievalAgentConfig):
|
|
64
|
+
preprocess = []
|
|
65
|
+
|
|
66
|
+
for agent_obj in config.preprocess:
|
|
67
|
+
agent_class = get_agent_klass(agent_obj.module)
|
|
68
|
+
preprocess.append(await agent_class.from_config(agent_obj))
|
|
69
|
+
|
|
70
|
+
context: list[ContextAgent] = []
|
|
71
|
+
for context_agent_obj in config.context:
|
|
72
|
+
agent_class = get_agent_klass(context_agent_obj.module)
|
|
73
|
+
context.append(await agent_class.from_config(context_agent_obj)) # type: ignore[arg-type]
|
|
74
|
+
|
|
75
|
+
generation = []
|
|
76
|
+
for generation_agent_obj in config.generation:
|
|
77
|
+
agent_class = get_agent_klass(generation_agent_obj.module)
|
|
78
|
+
generation.append(await agent_class.from_config(generation_agent_obj))
|
|
79
|
+
|
|
80
|
+
postprocess = []
|
|
81
|
+
for post_agent_obj in config.postprocess:
|
|
82
|
+
agent_class = get_agent_klass(post_agent_obj.module)
|
|
83
|
+
postprocess.append(await agent_class.from_config(post_agent_obj))
|
|
84
|
+
|
|
85
|
+
return cls(
|
|
86
|
+
preprocess=preprocess,
|
|
87
|
+
context=context,
|
|
88
|
+
postprocess=postprocess,
|
|
89
|
+
generation=generation,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@handle_stage_error("preprocess")
|
|
93
|
+
async def _run_preprocess(self, memory: QuestionMemory, manager: Manager):
|
|
94
|
+
if self.preprocess:
|
|
95
|
+
await asyncio.gather(
|
|
96
|
+
*[
|
|
97
|
+
preprocess(memory=memory, manager=manager)
|
|
98
|
+
for preprocess in self.preprocess
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@handle_stage_error("context")
|
|
103
|
+
async def _run_context(self, memory: QuestionMemory, manager: Manager):
|
|
104
|
+
if self.context and self.debug is False:
|
|
105
|
+
questions: List[tuple[str, str]] = memory.get_questions()
|
|
106
|
+
# Launch all context agents for all questions in parallel
|
|
107
|
+
tasks = [
|
|
108
|
+
generation.get_question_context(
|
|
109
|
+
memory,
|
|
110
|
+
manager,
|
|
111
|
+
question_uuid=question_uuid,
|
|
112
|
+
question=question,
|
|
113
|
+
flow_id=str(uuid4()),
|
|
114
|
+
)
|
|
115
|
+
for question_uuid, question in questions
|
|
116
|
+
for generation in self.context
|
|
117
|
+
]
|
|
118
|
+
await asyncio.gather(*tasks)
|
|
119
|
+
elif self.context and self.debug is True:
|
|
120
|
+
questions = memory.get_questions()
|
|
121
|
+
for generation in self.context:
|
|
122
|
+
for question_uuid, question in questions:
|
|
123
|
+
await generation.get_question_context(
|
|
124
|
+
memory,
|
|
125
|
+
manager,
|
|
126
|
+
question_uuid=question_uuid,
|
|
127
|
+
question=question,
|
|
128
|
+
flow_id=str(uuid4()),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@handle_stage_error("generation")
|
|
132
|
+
async def _run_generation(self, memory: QuestionMemory, manager: Manager):
|
|
133
|
+
if self.generation:
|
|
134
|
+
await asyncio.gather(
|
|
135
|
+
*[agent(memory, manager) for agent in self.generation],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@handle_stage_error("postprocess")
|
|
139
|
+
async def _run_postprocess(self, memory: QuestionMemory, manager: Manager):
|
|
140
|
+
if self.postprocess:
|
|
141
|
+
await asyncio.gather(
|
|
142
|
+
*[postprocess(memory, manager) for postprocess in self.postprocess],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def __call__(
|
|
146
|
+
self,
|
|
147
|
+
memory: QuestionMemory,
|
|
148
|
+
manager: Manager,
|
|
149
|
+
):
|
|
150
|
+
while memory.restart:
|
|
151
|
+
memory.restart = False
|
|
152
|
+
|
|
153
|
+
await self._run_preprocess(memory=memory, manager=manager)
|
|
154
|
+
if memory.secure is False:
|
|
155
|
+
memory.final_answer = "Insecure query"
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
await self._run_context(memory, manager)
|
|
159
|
+
|
|
160
|
+
if memory.final_answer is None:
|
|
161
|
+
await self._run_generation(memory, manager)
|
|
162
|
+
|
|
163
|
+
if memory.restart is False:
|
|
164
|
+
await memory.add_final_answer()
|
|
165
|
+
await self._run_postprocess(memory, manager)
|
|
166
|
+
|
|
167
|
+
if memory.secure is False:
|
|
168
|
+
memory.final_answer = "Insecure context retrieved"
|
|
169
|
+
break
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import Annotated, Any, Dict, Literal, Union
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, TypeAdapter, field_serializer, field_validator
|
|
5
|
+
|
|
6
|
+
from hyperforge.agent import AgentConfig
|
|
7
|
+
from hyperforge.configure import get_agent_config_klass, get_driver_config_klass
|
|
8
|
+
from hyperforge.driver import DriverConfig
|
|
9
|
+
from hyperforge.models import MemoryConfig, Rules
|
|
10
|
+
from hyperforge.prompts import PromptConfig
|
|
11
|
+
from hyperforge.workflows import WorkflowData
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RetrievalAgentConfig(BaseModel):
|
|
15
|
+
drivers: list[DriverConfig]
|
|
16
|
+
rules: Rules
|
|
17
|
+
memory: MemoryConfig
|
|
18
|
+
workflow: WorkflowData
|
|
19
|
+
|
|
20
|
+
preprocess: list[AgentConfig]
|
|
21
|
+
context: list[AgentConfig]
|
|
22
|
+
generation: list[AgentConfig]
|
|
23
|
+
postprocess: list[AgentConfig]
|
|
24
|
+
|
|
25
|
+
@field_serializer("preprocess", "context", "generation", "postprocess", "drivers")
|
|
26
|
+
def serialize_agents(self, agents: list[AgentConfig]) -> list[Dict[str, Any]]:
|
|
27
|
+
return [agent.model_dump() for agent in agents]
|
|
28
|
+
|
|
29
|
+
@field_validator("drivers", mode="before")
|
|
30
|
+
def validate_drivers(cls, value: list[Dict[str, Any]], field):
|
|
31
|
+
if len(value) == 0:
|
|
32
|
+
return []
|
|
33
|
+
if all([isinstance(agent, DriverConfig) for agent in value]):
|
|
34
|
+
return value
|
|
35
|
+
result = []
|
|
36
|
+
for agent_config in value:
|
|
37
|
+
module = agent_config["provider"]
|
|
38
|
+
agent_klass = get_driver_config_klass(module)
|
|
39
|
+
result.append(agent_klass.model_validate(agent_config))
|
|
40
|
+
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
@field_validator(
|
|
44
|
+
"preprocess", "context", "generation", "postprocess", mode="before"
|
|
45
|
+
)
|
|
46
|
+
def validate_agents(cls, value: list[Dict[str, Any]], field):
|
|
47
|
+
if len(value) == 0:
|
|
48
|
+
return []
|
|
49
|
+
if all([isinstance(agent, AgentConfig) for agent in value]):
|
|
50
|
+
return value
|
|
51
|
+
result = []
|
|
52
|
+
for agent_config in value:
|
|
53
|
+
module = agent_config["module"]
|
|
54
|
+
agent_klass = get_agent_config_klass(module)
|
|
55
|
+
result.append(agent_klass.model_validate(agent_config))
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
def is_empty(self) -> bool:
|
|
60
|
+
return (
|
|
61
|
+
len(self.drivers) == 0
|
|
62
|
+
and len(self.preprocess) == 0
|
|
63
|
+
and len(self.context) == 0
|
|
64
|
+
and len(self.generation) == 0
|
|
65
|
+
and len(self.postprocess) == 0
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class RetrievalAgentExportV1(BaseModel):
|
|
70
|
+
version: Literal["1"] = Field(default="1")
|
|
71
|
+
agent_config: RetrievalAgentConfig | None
|
|
72
|
+
agent_config_workflows: dict[str, RetrievalAgentConfig] = Field(
|
|
73
|
+
default_factory=dict
|
|
74
|
+
)
|
|
75
|
+
prompts: list[PromptConfig]
|
|
76
|
+
timestamp: str = Field(
|
|
77
|
+
default_factory=lambda: datetime.datetime.now(datetime.UTC).isoformat(
|
|
78
|
+
timespec="seconds"
|
|
79
|
+
),
|
|
80
|
+
description="Timestamp of when the export was created in UTC",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
RetrievalAgentExport = Annotated[
|
|
85
|
+
Union[RetrievalAgentExportV1], Field(discriminator="version")
|
|
86
|
+
]
|
|
87
|
+
retrievalAgentAdapter = TypeAdapter(RetrievalAgentExport)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RetrievalAgentExportRequest(BaseModel):
|
|
91
|
+
passphrase: str = Field(
|
|
92
|
+
description="Passphrase to encrypt the exported configuration. Will be required for import.",
|
|
93
|
+
min_length=16,
|
|
94
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from lru import LRU
|
|
2
|
+
from redis.asyncio import Redis
|
|
3
|
+
|
|
4
|
+
from hyperforge.models import HistoryQuestionAnswer, Source
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cache:
|
|
8
|
+
async def get(self, key: str) -> str | None:
|
|
9
|
+
raise NotImplementedError()
|
|
10
|
+
|
|
11
|
+
async def set(self, key: str, value: str, expire: int):
|
|
12
|
+
raise NotImplementedError()
|
|
13
|
+
|
|
14
|
+
async def get_list(self, key: str) -> list[str] | None:
|
|
15
|
+
raise NotImplementedError()
|
|
16
|
+
|
|
17
|
+
async def append(self, key: str, values: list[str], limit: int, expire: int):
|
|
18
|
+
raise NotImplementedError()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NoCache(Cache):
|
|
22
|
+
async def get(self, key: str) -> str | None:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
async def get_list(self, key: str) -> list[str] | None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
async def set(self, key: str, value: str, expire: int):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
async def append(self, key: str, values: list[str], limit: int, expire: int):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InMemoryCache(Cache):
|
|
36
|
+
_store: LRU
|
|
37
|
+
_list_store: LRU
|
|
38
|
+
|
|
39
|
+
def __init__(self, size: int = 3000):
|
|
40
|
+
self._store = LRU(size)
|
|
41
|
+
self._list_store = LRU(size)
|
|
42
|
+
|
|
43
|
+
async def get(self, key: str) -> str | None:
|
|
44
|
+
return self._store.get(key)
|
|
45
|
+
|
|
46
|
+
async def get_list(self, key: str) -> list[str] | None:
|
|
47
|
+
return self._list_store.get(key)
|
|
48
|
+
|
|
49
|
+
async def set(self, key: str, value: str, expire: int):
|
|
50
|
+
self._store[key] = value
|
|
51
|
+
|
|
52
|
+
async def append(self, key: str, values: list[str], limit: int, expire: int):
|
|
53
|
+
if key not in self._list_store:
|
|
54
|
+
self._list_store[key] = []
|
|
55
|
+
self._list_store[key].extend(values)
|
|
56
|
+
# Enforce limit
|
|
57
|
+
if len(self._list_store[key]) > limit:
|
|
58
|
+
self._list_store[key] = self._list_store[key][-limit:]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ValkeyCache(Cache):
|
|
62
|
+
def __init__(self, client: Redis):
|
|
63
|
+
self.client = client
|
|
64
|
+
|
|
65
|
+
async def get(self, key: str) -> str | None:
|
|
66
|
+
return await self.client.get(key)
|
|
67
|
+
|
|
68
|
+
async def get_list(self, key: str) -> list[str] | None:
|
|
69
|
+
list = await self.client.lrange(key, 0, -1)
|
|
70
|
+
if len(list) == 0:
|
|
71
|
+
# This can mean empty list or non-cached, be specific in return
|
|
72
|
+
if await self.client.exists(key):
|
|
73
|
+
return []
|
|
74
|
+
else:
|
|
75
|
+
return None
|
|
76
|
+
else:
|
|
77
|
+
return list
|
|
78
|
+
|
|
79
|
+
async def set(self, key: str, value: str, expire: int):
|
|
80
|
+
await self.client.set(key, value, ex=expire)
|
|
81
|
+
|
|
82
|
+
async def append(self, key: str, values: list[str], limit: int, expire: int):
|
|
83
|
+
async with self.client.pipeline() as multi:
|
|
84
|
+
await (
|
|
85
|
+
multi.rpush(key, *values)
|
|
86
|
+
.ltrim(key, -limit, -1)
|
|
87
|
+
.expire(key, expire)
|
|
88
|
+
.execute()
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# The following are small wrapper classes for each cache item, to ensure they are
|
|
93
|
+
# always accessed in a consistent way.
|
|
94
|
+
#
|
|
95
|
+
# Key design notes:
|
|
96
|
+
# - Components separated by dot (.)
|
|
97
|
+
# - From greater to lesser specificity (so it's possible to invalidate by prefix)
|
|
98
|
+
# - Keys include descriptive components before IDs so it's easy to see what a random hex number means
|
|
99
|
+
class CachedNucliaDBSource:
|
|
100
|
+
def __init__(self, cache: Cache, agent_id: str, source: str):
|
|
101
|
+
self._cache = cache
|
|
102
|
+
self._key = f"cache.agent.{agent_id}.source.{source}"
|
|
103
|
+
|
|
104
|
+
async def get(self) -> Source | None:
|
|
105
|
+
value = await self._cache.get(self._key)
|
|
106
|
+
if value is None:
|
|
107
|
+
return None
|
|
108
|
+
return Source.model_validate_json(value)
|
|
109
|
+
|
|
110
|
+
async def set(self, source: Source):
|
|
111
|
+
await self._cache.set(self._key, source.model_dump_json(), expire=900)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class CachedSessionQA:
|
|
115
|
+
def __init__(self, cache: Cache, agent_id: str, session: str):
|
|
116
|
+
self._cache = cache
|
|
117
|
+
self._key = f"cache.agent.{agent_id}.session.{session}.qa_history"
|
|
118
|
+
|
|
119
|
+
async def get(self) -> list[HistoryQuestionAnswer] | None:
|
|
120
|
+
value = await self._cache.get_list(self._key)
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
return [HistoryQuestionAnswer.model_validate_json(v) for v in value]
|
|
124
|
+
|
|
125
|
+
async def append(self, qa: HistoryQuestionAnswer):
|
|
126
|
+
await self.append_all([qa])
|
|
127
|
+
|
|
128
|
+
async def append_all(self, qas: list[HistoryQuestionAnswer]):
|
|
129
|
+
await self._cache.append(
|
|
130
|
+
self._key, [qa.model_dump_json() for qa in qas], limit=20, expire=3600
|
|
131
|
+
)
|
hyperforge/server/run.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import sentry_sdk
|
|
6
|
+
from hyperforge_database.agents import AgentManager
|
|
7
|
+
from hyperforge_database.settings import DataManagerSettings
|
|
8
|
+
from hyperforge_server import SERVICE_NAME
|
|
9
|
+
from hyperforge_server.cache import ValkeyCache
|
|
10
|
+
from hyperforge_server.session import SessionManager
|
|
11
|
+
from hyperforge_server.settings import Settings
|
|
12
|
+
from nucliadb_telemetry.fastapi import application_metrics
|
|
13
|
+
from nucliadb_telemetry.logs import setup_logging
|
|
14
|
+
from nucliadb_telemetry.settings import LogLevel, LogSettings
|
|
15
|
+
from nucliadb_telemetry.tracerprovider import AsyncTracerProvider
|
|
16
|
+
from nucliadb_telemetry.utils import get_telemetry, setup_telemetry
|
|
17
|
+
from sentry_sdk.integrations.excepthook import ExcepthookIntegration
|
|
18
|
+
|
|
19
|
+
from hyperforge.broker.redis import RedisBroker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_sentry(zone: str, environment: str, sentry_url: str):
|
|
23
|
+
sentry_exception = ExcepthookIntegration(always_run=True)
|
|
24
|
+
sentry_sdk.init(
|
|
25
|
+
release=version("hyperforge"),
|
|
26
|
+
environment=environment,
|
|
27
|
+
dsn=sentry_url,
|
|
28
|
+
integrations=[sentry_exception],
|
|
29
|
+
)
|
|
30
|
+
sentry_sdk.set_tag("zone", zone)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def run_metrics_server(port: int):
|
|
34
|
+
import uvicorn
|
|
35
|
+
|
|
36
|
+
config = uvicorn.Config(
|
|
37
|
+
application_metrics, host="0.0.0.0", port=port, log_level="info"
|
|
38
|
+
)
|
|
39
|
+
server = uvicorn.Server(config)
|
|
40
|
+
await server.serve()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def run_server(
|
|
44
|
+
settings: Settings,
|
|
45
|
+
tracer: Optional[AsyncTracerProvider],
|
|
46
|
+
data_manager_settings: DataManagerSettings,
|
|
47
|
+
) -> SessionManager:
|
|
48
|
+
if tracer:
|
|
49
|
+
await setup_telemetry(SERVICE_NAME)
|
|
50
|
+
# Connect to Valkey
|
|
51
|
+
broker = RedisBroker.from_url(
|
|
52
|
+
url=settings.valkey_url,
|
|
53
|
+
activate_subject=settings.activate_subject,
|
|
54
|
+
keepalive_ms=int(settings.pubsub_keepalive_seconds * 1000),
|
|
55
|
+
cluster_mode=settings.valkey_cluster_mode,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
agent_manager = await AgentManager.from_settings(
|
|
59
|
+
settings=data_manager_settings,
|
|
60
|
+
)
|
|
61
|
+
await agent_manager.initialize()
|
|
62
|
+
|
|
63
|
+
session = SessionManager(
|
|
64
|
+
settings=settings,
|
|
65
|
+
broker=broker,
|
|
66
|
+
agent_manager=agent_manager,
|
|
67
|
+
cache=ValkeyCache(broker._client),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return session
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run(): # pragma: no cover
|
|
74
|
+
settings = Settings()
|
|
75
|
+
setup_logging(
|
|
76
|
+
settings=LogSettings(
|
|
77
|
+
debug=settings.debug,
|
|
78
|
+
log_level=LogLevel(settings.log_level),
|
|
79
|
+
logger_levels={
|
|
80
|
+
"uvicorn.error": LogLevel.ERROR,
|
|
81
|
+
"nucliadb_telemetry": LogLevel.ERROR,
|
|
82
|
+
"mcp.client.streamable_http": LogLevel.WARNING,
|
|
83
|
+
"mcp.server.lowlevel.server": LogLevel.WARNING,
|
|
84
|
+
"hyperforge.configure": LogLevel.WARNING,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
data_manager_settings = DataManagerSettings()
|
|
89
|
+
tracer = get_telemetry("nuclia-arag-server")
|
|
90
|
+
if settings.sentry_url is not None:
|
|
91
|
+
set_sentry(
|
|
92
|
+
settings.zone,
|
|
93
|
+
settings.running_environment,
|
|
94
|
+
settings.sentry_url,
|
|
95
|
+
)
|
|
96
|
+
loop = asyncio.get_event_loop()
|
|
97
|
+
|
|
98
|
+
loop.create_task(run_metrics_server(settings.metrics_port))
|
|
99
|
+
|
|
100
|
+
session = loop.run_until_complete(
|
|
101
|
+
run_server(settings, tracer, data_manager_settings)
|
|
102
|
+
)
|
|
103
|
+
loop.run_until_complete(session.initialize())
|
|
104
|
+
try:
|
|
105
|
+
loop.run_forever()
|
|
106
|
+
finally:
|
|
107
|
+
loop.run_until_complete(session.finalize())
|
|
108
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
109
|
+
loop.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
import sentry_sdk
|
|
5
|
+
from nucliadb_telemetry.fastapi import application_metrics
|
|
6
|
+
from nucliadb_telemetry.logs import setup_logging
|
|
7
|
+
from nucliadb_telemetry.settings import LogLevel, LogSettings
|
|
8
|
+
from sentry_sdk.integrations.excepthook import ExcepthookIntegration
|
|
9
|
+
|
|
10
|
+
from agents.restricted.src.hyperforge_restricted import sandbox
|
|
11
|
+
from hyperforge.server.settings import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_sentry(zone: str, environment: str, sentry_url: str):
|
|
15
|
+
sentry_exception = ExcepthookIntegration(always_run=True)
|
|
16
|
+
sentry_sdk.init(
|
|
17
|
+
release=version("hyperforge"),
|
|
18
|
+
environment=environment,
|
|
19
|
+
dsn=sentry_url,
|
|
20
|
+
integrations=[sentry_exception],
|
|
21
|
+
)
|
|
22
|
+
sentry_sdk.set_tag("zone", zone)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def run_metrics_server(port: int):
|
|
26
|
+
import uvicorn
|
|
27
|
+
|
|
28
|
+
config = uvicorn.Config(
|
|
29
|
+
application_metrics, host="0.0.0.0", port=port, log_level="info"
|
|
30
|
+
)
|
|
31
|
+
server = uvicorn.Server(config)
|
|
32
|
+
await server.serve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(): # pragma: no cover
|
|
36
|
+
settings = Settings()
|
|
37
|
+
sandbox_settings = sandbox.SandboxSettings()
|
|
38
|
+
setup_logging(
|
|
39
|
+
settings=LogSettings(
|
|
40
|
+
debug=settings.debug,
|
|
41
|
+
log_level=LogLevel(settings.log_level),
|
|
42
|
+
logger_levels={
|
|
43
|
+
"uvicorn.error": LogLevel.ERROR,
|
|
44
|
+
"nucliadb_telemetry": LogLevel.ERROR,
|
|
45
|
+
"mcp.client.streamable_http": LogLevel.WARNING,
|
|
46
|
+
"mcp.server.lowlevel.server": LogLevel.WARNING,
|
|
47
|
+
"hyperforge.configure": LogLevel.WARNING,
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
loop = asyncio.get_event_loop()
|
|
53
|
+
|
|
54
|
+
loop.create_task(run_metrics_server(sandbox_settings.sandbox_metrics_port))
|
|
55
|
+
loop.create_task(sandbox.run_sandbox_server())
|
|
56
|
+
try:
|
|
57
|
+
loop.run_forever()
|
|
58
|
+
finally:
|
|
59
|
+
loop.run_until_complete(loop.shutdown_asyncgens())
|
|
60
|
+
loop.close()
|