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.
Files changed (90) hide show
  1. hyperforge/__init__.py +16 -0
  2. hyperforge/agent.py +81 -0
  3. hyperforge/api/__init__.py +20 -0
  4. hyperforge/api/app.py +155 -0
  5. hyperforge/api/authentication.py +271 -0
  6. hyperforge/api/commands.py +33 -0
  7. hyperforge/api/internal/__init__.py +4 -0
  8. hyperforge/api/internal/inspect.py +30 -0
  9. hyperforge/api/internal/router.py +3 -0
  10. hyperforge/api/logging.py +18 -0
  11. hyperforge/api/models.py +129 -0
  12. hyperforge/api/session.py +197 -0
  13. hyperforge/api/settings.py +38 -0
  14. hyperforge/api/utils.py +354 -0
  15. hyperforge/api/v1/__init__.py +23 -0
  16. hyperforge/api/v1/agents.py +531 -0
  17. hyperforge/api/v1/interaction.py +430 -0
  18. hyperforge/api/v1/mcp_content.py +311 -0
  19. hyperforge/api/v1/mcp_interaction.py +322 -0
  20. hyperforge/api/v1/oauth.py +60 -0
  21. hyperforge/api/v1/prompt.py +129 -0
  22. hyperforge/api/v1/router.py +3 -0
  23. hyperforge/api/v1/schema.py +56 -0
  24. hyperforge/api/v1/session.py +182 -0
  25. hyperforge/api/v1/utils.py +12 -0
  26. hyperforge/api/v1/workflows.py +643 -0
  27. hyperforge/arag.py +28 -0
  28. hyperforge/broker/__init__.py +52 -0
  29. hyperforge/broker/local.py +116 -0
  30. hyperforge/broker/redis.py +161 -0
  31. hyperforge/configure.py +571 -0
  32. hyperforge/context/__init__.py +0 -0
  33. hyperforge/context/agent.py +377 -0
  34. hyperforge/context/config.py +103 -0
  35. hyperforge/database.py +3 -0
  36. hyperforge/db/__init__.py +6 -0
  37. hyperforge/db/agents.py +1521 -0
  38. hyperforge/db/encryption.py +91 -0
  39. hyperforge/db/exceptions.py +26 -0
  40. hyperforge/db/settings.py +16 -0
  41. hyperforge/db/workflow_cleanup.py +69 -0
  42. hyperforge/definition.py +13 -0
  43. hyperforge/driver.py +31 -0
  44. hyperforge/dummy.py +28 -0
  45. hyperforge/engine.py +189 -0
  46. hyperforge/exceptions.py +14 -0
  47. hyperforge/feature_flag.py +105 -0
  48. hyperforge/fixtures.py +602 -0
  49. hyperforge/interaction.py +116 -0
  50. hyperforge/llm.py +75 -0
  51. hyperforge/manager.py +432 -0
  52. hyperforge/memory/__init__.py +5 -0
  53. hyperforge/memory/memory.py +974 -0
  54. hyperforge/minimal_fixtures.py +75 -0
  55. hyperforge/models.py +336 -0
  56. hyperforge/nua.py +336 -0
  57. hyperforge/openapi.py +63 -0
  58. hyperforge/prompts.py +188 -0
  59. hyperforge/pubsub.py +90 -0
  60. hyperforge/py.typed +0 -0
  61. hyperforge/redis_utils.py +82 -0
  62. hyperforge/retrieval/__init__.py +0 -0
  63. hyperforge/retrieval/agent.py +169 -0
  64. hyperforge/retrieval/config.py +94 -0
  65. hyperforge/server/__init__.py +5 -0
  66. hyperforge/server/cache.py +131 -0
  67. hyperforge/server/run.py +109 -0
  68. hyperforge/server/sandbox.py +60 -0
  69. hyperforge/server/session.py +421 -0
  70. hyperforge/server/settings.py +47 -0
  71. hyperforge/server/utils.py +57 -0
  72. hyperforge/server/web.py +31 -0
  73. hyperforge/settings.py +18 -0
  74. hyperforge/standalone/__init__.py +5 -0
  75. hyperforge/standalone/agent.py +189 -0
  76. hyperforge/standalone/app.py +264 -0
  77. hyperforge/standalone/config.py +137 -0
  78. hyperforge/standalone/const.py +1 -0
  79. hyperforge/standalone/run.py +60 -0
  80. hyperforge/standalone/settings.py +133 -0
  81. hyperforge/standalone/ui_router.py +241 -0
  82. hyperforge/trace.py +42 -0
  83. hyperforge/utils/__init__.py +112 -0
  84. hyperforge/utils/http.py +48 -0
  85. hyperforge/workflows.py +44 -0
  86. hyperforge-1.0.0.post19.dist-info/METADATA +95 -0
  87. hyperforge-1.0.0.post19.dist-info/RECORD +90 -0
  88. hyperforge-1.0.0.post19.dist-info/WHEEL +5 -0
  89. hyperforge-1.0.0.post19.dist-info/entry_points.txt +8 -0
  90. hyperforge-1.0.0.post19.dist-info/top_level.txt +1 -0
@@ -0,0 +1,430 @@
1
+ import asyncio
2
+ import uuid
3
+ from enum import Enum
4
+ from typing import TYPE_CHECKING, AsyncIterator, Optional
5
+
6
+ import opentelemetry.propagate
7
+ from fastapi import (
8
+ Header,
9
+ Query,
10
+ Request,
11
+ WebSocket,
12
+ WebSocketDisconnect,
13
+ )
14
+ from fastapi.responses import StreamingResponse
15
+ from nucliadb_sdk import NucliaDBAsync
16
+ from pydantic import ValidationError
17
+
18
+ from hyperforge import logger
19
+ from hyperforge.api.authentication import requires_one
20
+ from hyperforge.api.models import AgentRole, InteractionRequest
21
+ from hyperforge.api.session import create_session_resource, session_exists
22
+ from hyperforge.api.settings import Settings
23
+ from hyperforge.api.utils import agent_has_nucliadb_memory
24
+ from hyperforge.api.v1.router import router
25
+ from hyperforge.api.v1.utils import tracer
26
+ from hyperforge.broker import AgentTimeoutError
27
+ from hyperforge.db import exceptions
28
+ from hyperforge.db.agents import AgentManager
29
+ from hyperforge.interaction import (
30
+ AnswerOperation,
31
+ AragAnswer,
32
+ ARAGException,
33
+ )
34
+ from hyperforge.pubsub import (
35
+ AgentAnswer,
36
+ AgentDone,
37
+ AgentPing,
38
+ AgentToUserRequest,
39
+ OAuthRequest,
40
+ StartInteraction,
41
+ UserToAgentInteraction,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from hyperforge.api.app import HTTPApplication
46
+
47
+
48
+ async def ensure_session_exists(
49
+ ndb: NucliaDBAsync,
50
+ agent_id: str,
51
+ session: str,
52
+ create_if_not_exists: bool,
53
+ ) -> Optional[str]:
54
+ """
55
+ Check if session exists and create it if needed.
56
+
57
+ Returns:
58
+ None if session exists or was created successfully.
59
+ Error message string if session doesn't exist and shouldn't be created.
60
+ """
61
+ # Check if session exists
62
+ if await session_exists(ndb, agent_id, session):
63
+ return None
64
+
65
+ # Session doesn't exist
66
+ if not create_if_not_exists:
67
+ return f"Session '{session}' does not exist"
68
+
69
+ # Create the session
70
+ try:
71
+ await create_session_resource(
72
+ ndb=ndb,
73
+ agent_id=agent_id,
74
+ slug=session,
75
+ title=f"Session {session}",
76
+ summary="Auto-created session",
77
+ data="",
78
+ )
79
+ return None
80
+ except Exception as e:
81
+ logger.exception(f"Error creating session {session} for agent {agent_id}: {e}")
82
+ return f"Failed to create session: {str(e)}"
83
+
84
+
85
+ class Shutdown:
86
+ pass
87
+
88
+
89
+ class WebsocketReceiver:
90
+ class Expecting(str, Enum):
91
+ NOTHING = "nothing"
92
+ QUESTION = "question"
93
+ FEEDBACK = "feedback"
94
+
95
+ expecting: Expecting
96
+ websocket: WebSocket | None
97
+ queue: asyncio.Queue[InteractionRequest | UserToAgentInteraction | Shutdown]
98
+
99
+ def __init__(self, websocket: WebSocket | None):
100
+ self.expecting = WebsocketReceiver.Expecting.QUESTION
101
+ self.websocket = websocket
102
+ self.queue = asyncio.Queue(maxsize=1)
103
+
104
+ async def receive_question(self) -> InteractionRequest:
105
+ self.expecting = WebsocketReceiver.Expecting.QUESTION
106
+ msg = await self.queue.get()
107
+ if isinstance(msg, Shutdown):
108
+ raise WebSocketDisconnect()
109
+ if not isinstance(msg, InteractionRequest):
110
+ raise ValueError(f"Expected question but got {type(msg).__name__}")
111
+ return msg
112
+
113
+ async def receive_feedback(self) -> UserToAgentInteraction:
114
+ self.expecting = WebsocketReceiver.Expecting.FEEDBACK
115
+ msg = await self.queue.get()
116
+ if isinstance(msg, Shutdown):
117
+ raise WebSocketDisconnect()
118
+ if not isinstance(msg, UserToAgentInteraction):
119
+ raise ValueError(f"Expected feedback but got {type(msg).__name__}")
120
+ return msg
121
+
122
+ async def run(self):
123
+ if self.websocket is None:
124
+ raise ValueError("No websocket provided")
125
+ try:
126
+ async for message in self.websocket.iter_json():
127
+ match self.expecting:
128
+ case WebsocketReceiver.Expecting.NOTHING:
129
+ await self.websocket.send_json(
130
+ AragAnswer(
131
+ exception=ARAGException(
132
+ detail="Unexpected message from user"
133
+ ),
134
+ operation=AnswerOperation.ERROR,
135
+ ).model_dump()
136
+ )
137
+ case WebsocketReceiver.Expecting.QUESTION:
138
+ try:
139
+ await self.queue.put(
140
+ InteractionRequest.model_validate(message)
141
+ )
142
+ self.expecting = WebsocketReceiver.Expecting.NOTHING
143
+ except ValidationError as e:
144
+ await self.websocket.send_json(
145
+ AragAnswer(
146
+ exception=ARAGException(
147
+ detail="Invalid request payload",
148
+ extra={"validation_errors": e.errors()},
149
+ ),
150
+ operation=AnswerOperation.ERROR,
151
+ ).model_dump()
152
+ )
153
+ case WebsocketReceiver.Expecting.FEEDBACK:
154
+ try:
155
+ await self.queue.put(
156
+ UserToAgentInteraction.model_validate(message)
157
+ )
158
+ self.expecting = WebsocketReceiver.Expecting.NOTHING
159
+ except ValidationError as e:
160
+ await self.websocket.send_json(
161
+ AragAnswer(
162
+ exception=ARAGException(
163
+ detail="Invalid request payload",
164
+ extra={"validation_errors": e.errors()},
165
+ ),
166
+ operation=AnswerOperation.ERROR,
167
+ ).model_dump()
168
+ )
169
+ finally:
170
+ await self.queue.put(Shutdown())
171
+
172
+
173
+ async def stream_response(
174
+ app: "HTTPApplication",
175
+ websocket: Optional[WebsocketReceiver],
176
+ account: str,
177
+ agent_id: str,
178
+ session: str,
179
+ interaction: InteractionRequest,
180
+ workflow_id: str = "default",
181
+ ) -> AsyncIterator[AragAnswer]:
182
+ settings: Settings = app.settings
183
+ agent_manager: AgentManager = app.agent_manager
184
+
185
+ try:
186
+ await agent_manager.ensure_workflow_active(account, agent_id, workflow_id)
187
+ except exceptions.NotFoundError as exc:
188
+ yield AragAnswer(
189
+ exception=ARAGException(detail=str(exc)),
190
+ operation=AnswerOperation.ERROR,
191
+ )
192
+ return
193
+
194
+ question_id = uuid.uuid4().hex
195
+ subject = settings.answers_subject.format(
196
+ account=account,
197
+ agent_id=agent_id,
198
+ session=session,
199
+ question=question_id,
200
+ workflow_id=workflow_id,
201
+ )
202
+
203
+ request = StartInteraction(
204
+ account=account,
205
+ agent_id=agent_id,
206
+ session=session,
207
+ question_id=question_id,
208
+ question=interaction.question,
209
+ headers=interaction.headers,
210
+ arguments=interaction.arguments,
211
+ workflow_id=workflow_id,
212
+ streaming=interaction.streaming,
213
+ )
214
+
215
+ with tracer().start_as_current_span("Request activation"):
216
+ trace_headers: dict[str, str] = {}
217
+ opentelemetry.propagate.inject(trace_headers)
218
+ await app.broker.publish_activation(request, trace_headers)
219
+ try:
220
+ async for _cursor, obj in app.broker.subscribe(subject):
221
+ if isinstance(obj, AgentAnswer):
222
+ yield obj.answer
223
+ elif isinstance(obj, OAuthRequest):
224
+ if websocket is None:
225
+ yield AragAnswer(
226
+ exception=ARAGException(
227
+ detail="Agent requires OAuth which is only supported via websocket"
228
+ ),
229
+ operation=AnswerOperation.ERROR,
230
+ )
231
+ return
232
+
233
+ yield AragAnswer(
234
+ operation=AnswerOperation.AGENT_REQUEST, oauth=obj.oauth
235
+ )
236
+ elif isinstance(obj, AgentToUserRequest):
237
+ if websocket is None:
238
+ yield AragAnswer(
239
+ exception=ARAGException(
240
+ detail="Agent requires elicitation which is only supported via websocket"
241
+ ),
242
+ operation=AnswerOperation.ERROR,
243
+ )
244
+ return
245
+
246
+ yield AragAnswer(
247
+ operation=AnswerOperation.AGENT_REQUEST, feedback=obj.feedback
248
+ )
249
+ try:
250
+ user_response = await websocket.receive_feedback()
251
+ except ValueError as e:
252
+ # Wrong message type received
253
+ yield AragAnswer(
254
+ exception=ARAGException(detail=f"Unexpected message: {str(e)}"),
255
+ operation=AnswerOperation.ERROR,
256
+ )
257
+ return
258
+ except WebSocketDisconnect:
259
+ return
260
+ await app.broker.send_reply(
261
+ obj.feedback.feedback_id, user_response.model_dump_json()
262
+ )
263
+ elif isinstance(obj, AgentDone):
264
+ yield AragAnswer(operation=AnswerOperation.DONE)
265
+ break
266
+ elif isinstance(obj, AgentPing):
267
+ pass
268
+ elif isinstance(obj, UserToAgentInteraction):
269
+ # TODO: Stream the user response for full history when resuming
270
+ pass
271
+ else:
272
+ yield AragAnswer(
273
+ exception=ARAGException(detail="Unknown message from agent"),
274
+ operation=AnswerOperation.ERROR,
275
+ )
276
+ raise Exception("Unknown message from agent")
277
+ except AgentTimeoutError:
278
+ yield AragAnswer(
279
+ exception=ARAGException(
280
+ detail="Agent has stopped responding. Please, try again."
281
+ ),
282
+ operation=AnswerOperation.ERROR,
283
+ )
284
+
285
+
286
+ async def close_websocket_with_error(
287
+ websocket: WebSocket, task: asyncio.Task, error_message: str
288
+ ):
289
+ """Send error message and close websocket connection."""
290
+ try:
291
+ await websocket.send_json(
292
+ AragAnswer(
293
+ exception=ARAGException(detail=error_message),
294
+ operation=AnswerOperation.ERROR,
295
+ ).model_dump()
296
+ )
297
+ except (RuntimeError, WebSocketDisconnect):
298
+ pass
299
+ finally:
300
+ task.cancel()
301
+ try:
302
+ await websocket.close()
303
+ except RuntimeError:
304
+ pass
305
+
306
+
307
+ @router.websocket("/api/v1/agent/{agent_id}/session/{session}/ws")
308
+ @router.websocket(
309
+ "/api/v1/agent/{agent_id}/workflow/{workflow_id}/session/{session}/ws"
310
+ )
311
+ @requires_one([AgentRole.MEMBER])
312
+ async def websocket_endpoint(
313
+ websocket: WebSocket,
314
+ agent_id: str,
315
+ session: str,
316
+ keep_open: bool = False,
317
+ workflow_id: str = "default",
318
+ create_session_if_not_exists: bool = Query(True),
319
+ x_stf_user: str = Header(..., include_in_schema=False),
320
+ x_stf_account: str = Header(..., include_in_schema=False),
321
+ x_stf_account_type: str = Header(..., include_in_schema=False),
322
+ ):
323
+ await websocket.accept()
324
+ receiver = WebsocketReceiver(websocket)
325
+ task = asyncio.create_task(receiver.run())
326
+
327
+ # Validate session if agent uses NucliaDB memory (skip for ephemeral sessions)
328
+ agent_manager: AgentManager = websocket.app.agent_manager
329
+ try:
330
+ await agent_manager.ensure_workflow_active(x_stf_account, agent_id, workflow_id)
331
+ if session != "ephemeral" and await agent_has_nucliadb_memory(
332
+ agent_manager, x_stf_account, agent_id, workflow_id
333
+ ):
334
+ ndb: NucliaDBAsync = websocket.app.arag_reader
335
+ error_message = await ensure_session_exists(
336
+ ndb, agent_id, session, create_session_if_not_exists
337
+ )
338
+ if error_message:
339
+ await close_websocket_with_error(websocket, task, error_message)
340
+ return
341
+ except Exception as e:
342
+ logger.exception(f"Error checking agent memory config: {e}")
343
+ await close_websocket_with_error(
344
+ websocket, task, f"Failed to verify agent configuration: {str(e)}"
345
+ )
346
+ return
347
+
348
+ # Wait for questions
349
+ first_question = True
350
+ while True:
351
+ if not keep_open and not first_question:
352
+ break
353
+ try:
354
+ interaction = await receiver.receive_question()
355
+ except WebSocketDisconnect:
356
+ break
357
+ except ValueError as e:
358
+ # Wrong message type received
359
+ await websocket.send_json(
360
+ AragAnswer(
361
+ exception=ARAGException(detail=f"Unexpected message: {str(e)}"),
362
+ operation=AnswerOperation.ERROR,
363
+ ).model_dump()
364
+ )
365
+ break
366
+
367
+ first_question = False
368
+ for header, header_value in websocket.headers.items():
369
+ interaction.headers[header] = header_value
370
+
371
+ async for msg in stream_response(
372
+ websocket.app,
373
+ receiver,
374
+ x_stf_account,
375
+ agent_id,
376
+ session,
377
+ interaction,
378
+ workflow_id=workflow_id,
379
+ ):
380
+ try:
381
+ await websocket.send_text(msg.model_dump_json())
382
+ except (RuntimeError, WebSocketDisconnect):
383
+ # WebSocket already closed
384
+ pass
385
+
386
+ try:
387
+ task.cancel()
388
+ await websocket.close()
389
+ except RuntimeError:
390
+ # WebSocket already closed
391
+ pass
392
+
393
+
394
+ @router.post(
395
+ "/api/v1/agent/{agent_id}/session/{session}",
396
+ status_code=200,
397
+ description="Interact session",
398
+ tags=["Retrieval Agent"],
399
+ )
400
+ @router.post(
401
+ "/api/v1/agent/{agent_id}/workflow/{workflow_id}/session/{session}",
402
+ status_code=200,
403
+ description="Interact session",
404
+ tags=["Retrieval Agent"],
405
+ )
406
+ @requires_one([AgentRole.MEMBER])
407
+ async def interaction(
408
+ request: Request,
409
+ agent_id: str,
410
+ session: str,
411
+ item: InteractionRequest,
412
+ workflow_id: str = "default",
413
+ x_stf_user: str = Header(..., include_in_schema=False),
414
+ x_stf_account: str = Header(..., include_in_schema=False),
415
+ x_stf_account_type: str = Header(..., include_in_schema=False),
416
+ ):
417
+ async def responder():
418
+ async for msg in stream_response(
419
+ request.app,
420
+ None,
421
+ x_stf_account,
422
+ agent_id,
423
+ session,
424
+ item,
425
+ workflow_id=workflow_id,
426
+ ):
427
+ yield msg.model_dump_json() + "\n"
428
+
429
+ # subscribe to
430
+ return StreamingResponse(responder(), media_type="application/x-ndjson")