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,974 @@
1
+ import logging
2
+ from collections import OrderedDict
3
+ from datetime import datetime, timezone
4
+ from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Union, cast
5
+ from uuid import uuid4
6
+
7
+ from nuclia.lib.nua_responses import Author, Image, Message
8
+ from nucliadb_models import (
9
+ InputMessage,
10
+ InputMessageContent,
11
+ MessageType,
12
+ )
13
+ from nucliadb_models.conversation import Conversation
14
+ from nucliadb_models.resource import (
15
+ Resource,
16
+ ResourceField,
17
+ )
18
+ from nucliadb_models.search import FindOptions, FindRequest, KnowledgeboxFindResults
19
+ from nucliadb_sdk.v2 import NucliaDBAsync
20
+ from nucliadb_sdk.v2.exceptions import NotFoundError
21
+ from redis.asyncio import Redis
22
+
23
+ from hyperforge.interaction import (
24
+ AnswerOperation,
25
+ AragAnswer,
26
+ Feedback,
27
+ OAuthAuthenticateURL,
28
+ StreamingChunk,
29
+ )
30
+ from hyperforge.models import (
31
+ Answer,
32
+ AnswerCitations,
33
+ Chunk,
34
+ Context,
35
+ HistoryQuestionAnswer,
36
+ MemoryConfig,
37
+ Rule,
38
+ Rules,
39
+ Source,
40
+ Step,
41
+ TrackingInfo,
42
+ Visualization,
43
+ )
44
+ from hyperforge.pubsub import UserToAgentInteraction
45
+ from hyperforge.server.cache import (
46
+ Cache,
47
+ CachedNucliaDBSource,
48
+ CachedSessionQA,
49
+ NoCache,
50
+ ValkeyCache,
51
+ )
52
+
53
+ logger = logging.getLogger("arag.memory")
54
+
55
+ # NucliaDB storage
56
+ QUESTION_ANSWERS_FIELD: str = "qas"
57
+ CONTEXT_FIELD: str = "context"
58
+ STEPS_FIELD: str = "steps"
59
+ USER_INFO_FIELD: str = "user_info"
60
+
61
+
62
+ class BaseSessionMemory:
63
+ # Session ID
64
+ id: str = "invalid"
65
+
66
+ agent_id: str = ""
67
+ workflow_id: str = ""
68
+ kbid: Optional[str] = None
69
+
70
+ # User information dictionary
71
+ # Stored as JSON field
72
+ user_info: Dict[str, str]
73
+
74
+ # Configuration State
75
+ rules: List[Union[Rule, str]]
76
+
77
+ @classmethod
78
+ def from_config(
79
+ cls, config: MemoryConfig, agent_id: str, workflow_id: str, rules: Rules
80
+ ):
81
+ memory = cls(config, agent_id, workflow_id, NoCache())
82
+ memory.rules = rules.rules
83
+ return memory
84
+
85
+ def __init__(
86
+ self, config: MemoryConfig, agent_id: str, workflow_id: str, cache: Cache
87
+ ):
88
+ self.cache = cache
89
+ self.agent_id = agent_id
90
+ self.workflow_id = workflow_id
91
+ self.user_info = {}
92
+ self.rules = []
93
+ nucliadb_config = config.nucliadb
94
+ self.kbid = nucliadb_config.kbid if nucliadb_config is not None else None
95
+
96
+ def init(self, session: str):
97
+ self.id = session
98
+
99
+ async def set_source(self, source: Source):
100
+ pass
101
+
102
+ async def get_source(self, source_id: str) -> Source | None:
103
+ return None
104
+
105
+ def context_user_info(self) -> str:
106
+ result = ""
107
+ for key, value in self.user_info.items():
108
+ result += f"- {key}: {value}\n"
109
+ return result
110
+
111
+ async def search_in_questions(self, question: str, all: bool):
112
+ return KnowledgeboxFindResults(total=0, resources={})
113
+
114
+ async def get_chat_history(self) -> List[Message]:
115
+ qas = await self.qa_history()
116
+ result = []
117
+ for qa in qas:
118
+ result.append(
119
+ Message(
120
+ author=Author.USER,
121
+ text=qa.question,
122
+ )
123
+ )
124
+ result.append(
125
+ Message(
126
+ author=Author.NUCLIA,
127
+ text=qa.answer,
128
+ )
129
+ )
130
+ return result
131
+
132
+ async def qa_history(self) -> list[HistoryQuestionAnswer]:
133
+ return []
134
+
135
+ async def context_history(self) -> Tuple[str, int]:
136
+ result = ""
137
+ interactions = 0
138
+ for qa in await self.qa_history():
139
+ result += f"- Question: {qa.question}\n"
140
+ result += f"- Answer: {qa.answer}\n"
141
+ interactions += 1
142
+ return result, interactions
143
+
144
+ def start_question(
145
+ self,
146
+ question: str,
147
+ actions: Optional[List[str]] = None,
148
+ question_id: str | None = None,
149
+ headers: Dict[str, str] = {},
150
+ arguments: Dict[str, str] = {},
151
+ streaming: bool = False,
152
+ ) -> "QuestionMemory":
153
+ return QuestionMemory(
154
+ self,
155
+ question,
156
+ actions,
157
+ question_id=question_id,
158
+ headers=headers,
159
+ arguments=arguments,
160
+ streaming=streaming,
161
+ )
162
+
163
+ async def save(self, question: "QuestionMemory") -> None:
164
+ pass
165
+
166
+
167
+ class NoMemorySessionMemory(BaseSessionMemory):
168
+ def __init__(
169
+ self, config: MemoryConfig, agent_id: str, workflow_id: str, cache: Cache
170
+ ):
171
+ self.cache = cache
172
+ self.user_info = {}
173
+ self.rules = []
174
+ self.agent_id = agent_id
175
+ self.workflow_id = workflow_id
176
+ self.debug = False
177
+
178
+ def start_question(
179
+ self,
180
+ question: str,
181
+ actions: Optional[List[str]] = None,
182
+ question_id: str | None = None,
183
+ headers: Dict[str, str] = {},
184
+ arguments: Dict[str, str] = {},
185
+ streaming: bool = False,
186
+ ) -> "QuestionMemory":
187
+ return QuestionMemory(
188
+ self,
189
+ question,
190
+ actions,
191
+ question_id=question_id,
192
+ headers=headers,
193
+ arguments=arguments,
194
+ streaming=streaming,
195
+ )
196
+
197
+ async def save(self, question: "QuestionMemory") -> None:
198
+ pass
199
+
200
+ async def qa_history(self) -> list[HistoryQuestionAnswer]:
201
+ return []
202
+
203
+
204
+ class EphemeralSessionMemory(BaseSessionMemory):
205
+ @classmethod
206
+ def from_config(
207
+ cls,
208
+ config: MemoryConfig,
209
+ agent_id: str,
210
+ workflow_id: str,
211
+ rules: Rules,
212
+ client: Optional[Redis] = None,
213
+ ):
214
+ if client is None:
215
+ memory = cls(config, agent_id, workflow_id, NoCache())
216
+ else:
217
+ memory = cls(config, agent_id, workflow_id, ValkeyCache(client=client))
218
+
219
+ memory.rules = rules.rules
220
+ return memory
221
+
222
+ def __init__(
223
+ self, config: MemoryConfig, agent_id: str, workflow_id: str, cache: Cache
224
+ ):
225
+ self.cache = cache
226
+ self.agent_id = agent_id
227
+ self.workflow_id = workflow_id
228
+ self.user_info = {}
229
+ self.rules = []
230
+ self.debug = False
231
+ self.interactions: List[QuestionMemory] = []
232
+
233
+ async def set_source(self, source: Source):
234
+ entry = CachedNucliaDBSource(
235
+ cache=self.cache, agent_id=self.agent_id, source=source.id
236
+ )
237
+ await entry.set(source)
238
+
239
+ async def get_source(self, source_id: str) -> Source | None:
240
+ entry = CachedNucliaDBSource(
241
+ cache=self.cache, agent_id=self.agent_id, source=source_id
242
+ )
243
+ return await entry.get()
244
+
245
+ def start_question(
246
+ self,
247
+ question: str,
248
+ actions: Optional[List[str]] = None,
249
+ question_id: str | None = None,
250
+ headers: Dict[str, str] = {},
251
+ arguments: Dict[str, str] = {},
252
+ streaming: bool = False,
253
+ ) -> "QuestionMemory":
254
+ return QuestionMemory(
255
+ self,
256
+ question,
257
+ actions,
258
+ question_id=question_id,
259
+ headers=headers,
260
+ arguments=arguments,
261
+ streaming=streaming,
262
+ )
263
+
264
+ async def save(self, question: "QuestionMemory") -> None:
265
+ self.interactions.append(question)
266
+ qa = HistoryQuestionAnswer(
267
+ question=question.original_question,
268
+ answer=question.final_answer or "",
269
+ )
270
+ await CachedSessionQA(self.cache, self.agent_id, self.id).append(qa)
271
+
272
+ async def qa_history(self) -> list[HistoryQuestionAnswer]:
273
+ cache_entry = CachedSessionQA(self.cache, self.agent_id, self.id)
274
+ cached_qa = await cache_entry.get()
275
+ if cached_qa is not None:
276
+ return cached_qa
277
+
278
+ return [
279
+ HistoryQuestionAnswer(
280
+ question=interaction.original_question,
281
+ answer=interaction.final_answer or "",
282
+ )
283
+ for interaction in self.interactions
284
+ ]
285
+
286
+
287
+ class SessionMemory(BaseSessionMemory):
288
+ def __init__(
289
+ self, config: MemoryConfig, agent_id: str, workflow_id: str, cache: Cache
290
+ ):
291
+ self.agent_id = agent_id
292
+ self.workflow_id = workflow_id
293
+ if config.nucliadb is not None:
294
+ self.url = config.nucliadb.url
295
+ self.key = config.nucliadb.key
296
+ self.kbid = config.nucliadb.kbid
297
+
298
+ if config.nucliadb.internal:
299
+ self.nucliadb_writer = NucliaDBAsync(
300
+ url=self.url.format(component="writer"),
301
+ api_key=self.key,
302
+ headers={"X-NUCLIADB-ROLES": "WRITER"},
303
+ )
304
+ self.nucliadb_reader = NucliaDBAsync(
305
+ url=self.url.format(component="reader"),
306
+ api_key=self.key,
307
+ headers={"X-NUCLIADB-ROLES": "READER"},
308
+ )
309
+ self.nucliadb_search = NucliaDBAsync(
310
+ url=self.url.format(component="search"),
311
+ api_key=self.key,
312
+ headers={"X-NUCLIADB-ROLES": "READER"},
313
+ )
314
+ else:
315
+ self.nucliadb_writer = NucliaDBAsync(url=self.url, api_key=self.key)
316
+ self.nucliadb_reader = NucliaDBAsync(url=self.url, api_key=self.key)
317
+ self.nucliadb_search = NucliaDBAsync(url=self.url, api_key=self.key)
318
+
319
+ self.cache = cache
320
+ self.user_info = {}
321
+ self.rules = []
322
+
323
+ def init(self, session: str):
324
+ self.id = session
325
+
326
+ @property
327
+ def debug(self):
328
+ return logger.level
329
+
330
+ @debug.setter
331
+ def debug(self, debug: bool):
332
+ if debug:
333
+ logger.setLevel(logging.INFO)
334
+ else:
335
+ logger.setLevel(logging.ERROR)
336
+
337
+ async def set_source(self, source: Source):
338
+ if self.kbid:
339
+ entry = CachedNucliaDBSource(
340
+ cache=self.cache, agent_id=self.kbid, source=source.id
341
+ )
342
+ await entry.set(source)
343
+
344
+ async def get_source(self, source_id: str) -> Source | None:
345
+ if not self.kbid:
346
+ return None
347
+
348
+ entry = CachedNucliaDBSource(
349
+ cache=self.cache, agent_id=self.kbid, source=source_id
350
+ )
351
+ return await entry.get()
352
+
353
+ async def search_in_questions(self, question: str, all: bool):
354
+ request = FindRequest(
355
+ query=question,
356
+ fields=["c/questions"],
357
+ min_score=0.9,
358
+ features=[FindOptions.SEMANTIC, FindOptions.KEYWORD],
359
+ )
360
+ if not all and self.id:
361
+ request.resource_filters = [self.id]
362
+ return await self.nucliadb_search.find(kbid=self.kbid, content=request)
363
+
364
+ async def qa_history(self) -> list[HistoryQuestionAnswer]:
365
+ if self.kbid is None:
366
+ return []
367
+
368
+ # Check if session Q&A is cached
369
+ cache_entry = CachedSessionQA(self.cache, self.kbid, self.id)
370
+ cached_qa = await cache_entry.get()
371
+ if cached_qa is not None:
372
+ return cached_qa
373
+
374
+ # Retrieve from memory
375
+ qas: list[HistoryQuestionAnswer] = []
376
+ try:
377
+ resource: Optional[
378
+ Resource
379
+ ] = await self.nucliadb_reader.get_resource_by_id(
380
+ kbid=self.kbid, rid=self.id, query_params={"show": ["values"]}
381
+ )
382
+ except NotFoundError:
383
+ resource = None
384
+
385
+ if (
386
+ resource is not None
387
+ and resource.data is not None
388
+ and resource.data.conversations is not None
389
+ ):
390
+ memory = resource.data.conversations.get(QUESTION_ANSWERS_FIELD)
391
+ if memory is None or memory.value is None or memory.value.pages is None:
392
+ raise NotImplementedError()
393
+ else:
394
+ for page_id in range(memory.value.pages):
395
+ page: Optional[
396
+ ResourceField
397
+ ] = await self.nucliadb_reader.get_resource_field(
398
+ kbid=self.kbid,
399
+ rid=self.id,
400
+ field_type="conversation",
401
+ field_id=QUESTION_ANSWERS_FIELD,
402
+ query_params={"page": page_id + 1},
403
+ )
404
+ if page is not None and page.value is not None:
405
+ conversation_value: Conversation = cast(
406
+ Conversation, page.value
407
+ )
408
+ question = None
409
+
410
+ for message in conversation_value.messages or []:
411
+ if message.type_ == MessageType.QUESTION:
412
+ question = message
413
+ elif (
414
+ message.type_ == MessageType.ANSWER
415
+ and question is not None
416
+ and question.content.text
417
+ and message.content.text
418
+ ):
419
+ qas.append(
420
+ HistoryQuestionAnswer(
421
+ question=question.content.text,
422
+ answer=message.content.text,
423
+ )
424
+ )
425
+ if len(qas) != 0:
426
+ await cache_entry.append_all(qas)
427
+
428
+ return qas
429
+
430
+ async def save(self, question: "QuestionMemory") -> None:
431
+ # # Find or create memory resource (should already be created by create_session())
432
+ # try:
433
+ # resource: Optional[
434
+ # Resource
435
+ # ] = await self.nucliadb_reader.get_resource_by_slug(
436
+ # kbid=self.kbid, slug=self.session
437
+ # )
438
+ # except NotFoundError:
439
+ # resource = None
440
+
441
+ # if resource is None:
442
+ # await self.nucliadb_writer.create_resource(
443
+ # kbid=self.kbid, content=CreateResourcePayload(slug=self.session)
444
+ # )
445
+ # resource = await self.nucliadb_reader.get_resource_by_id(
446
+ # kbid=self.kbid, slug=self.session
447
+ # )
448
+
449
+ # # Save steps
450
+ # steps_message = []
451
+ # for step in self.steps:
452
+ # steps_message.append(
453
+ # InputMessage(
454
+ # ident=uuid4().hex,
455
+ # who=step.module,
456
+ # type=MessageType.UNSET,
457
+ # content=InputMessageContent(
458
+ # text=step.markdown(), format=MessageFormat.KEEP_MARKDOWN
459
+ # ),
460
+ # )
461
+ # )
462
+
463
+ # await self.nucliadb_writer.add_conversation_message(
464
+ # kbid=self.kbid,
465
+ # rid=self.session,
466
+ # slug=STEPS_FIELD,
467
+ # content=steps_message,
468
+ # )
469
+
470
+ # # Save context
471
+ # contexts_message = []
472
+ # for context in self.contexts:
473
+ # contexts_message.append(
474
+ # InputMessage(
475
+ # ident=uuid4().hex,
476
+ # who="",
477
+ # type=MessageType.UNSET,
478
+ # content=InputMessageContent(
479
+ # text="", format=MessageFormat.KEEP_MARKDOWN
480
+ # ),
481
+ # )
482
+ # )
483
+
484
+ # await self.nucliadb_writer.add_conversation_message(
485
+ # kbid=self.kbid,
486
+ # rid=self.session,
487
+ # slug=CONTEXT_FIELD,
488
+ # content=contexts_message,
489
+ # )
490
+
491
+ # Save Q & A
492
+ if question.final_answer:
493
+ if self.kbid:
494
+ qa = HistoryQuestionAnswer(
495
+ question=question.original_question, answer=question.final_answer
496
+ )
497
+ await CachedSessionQA(self.cache, self.kbid, self.id).append(qa)
498
+
499
+ content = [
500
+ InputMessage(
501
+ who="user",
502
+ to=["agent"],
503
+ timestamp=question.started_at,
504
+ ident=f"q-{question.original_question_uuid}",
505
+ type=MessageType.QUESTION,
506
+ content=InputMessageContent(text=question.original_question),
507
+ ),
508
+ InputMessage(
509
+ who="agent",
510
+ to=["user"],
511
+ timestamp=datetime.now(timezone.utc),
512
+ ident=f"a-{question.original_question_uuid}",
513
+ type=MessageType.ANSWER,
514
+ content=InputMessageContent(text=question.final_answer),
515
+ ),
516
+ ]
517
+ await self.nucliadb_writer.add_conversation_message(
518
+ kbid=self.kbid,
519
+ rid=self.id,
520
+ field_id=QUESTION_ANSWERS_FIELD,
521
+ content=content,
522
+ )
523
+
524
+
525
+ class QuestionMemory:
526
+ session: BaseSessionMemory
527
+
528
+ headers: Dict[str, str]
529
+ arguments: Dict[str, str]
530
+
531
+ # Main RAG Block
532
+ started_at: datetime
533
+ original_actions: List[str]
534
+ original_question: str
535
+ original_question_uuid: str
536
+ final_answer: Optional[str] = None
537
+ final_answer_citations: Optional[AnswerCitations] = None
538
+ final_answer_urls: List[str]
539
+ answers: list[tuple[str, Optional[AnswerCitations]]]
540
+ generated_texts: Dict[str, str]
541
+ # Data visualizations generated
542
+ data_visualizations: List[Visualization]
543
+
544
+ # Whether after generation, we consider the question answered (for example we might have generated a response but the response was "not enough data to answer this")
545
+ is_answered: bool = False
546
+
547
+ # Callback information to return to user
548
+ callback_fn: Optional[Callable[[AragAnswer], Awaitable[None]]] = None
549
+ feedback_fn: Optional[
550
+ Callable[[Feedback], Awaitable[UserToAgentInteraction | None]]
551
+ ] = None
552
+ oauth_fn: Optional[Callable[[OAuthAuthenticateURL], Awaitable[None]]] = None
553
+ oauth_callback_fn: Optional[Callable[[str, str], Awaitable[str | None]]] = None
554
+
555
+ # Short term memory
556
+ steps: List[Step]
557
+ contexts: List[Context]
558
+ agent_contexts: Dict[str, Dict[str, List[Context]]]
559
+
560
+ # Flow controls
561
+ restart: bool = False
562
+ secure: Optional[bool] = None
563
+ streaming: bool = False
564
+
565
+ future_questions: OrderedDict[str, str]
566
+ context_questions: OrderedDict[str, str]
567
+ actual_question: Optional[str] = None
568
+ actual_question_uuid: Optional[str] = None
569
+
570
+ generation_rules: OrderedDict[str, str]
571
+ actual_action: Optional[str] = None
572
+ actual_action_uuid: Optional[str] = None
573
+
574
+ def __init__(
575
+ self,
576
+ session: BaseSessionMemory,
577
+ question: str,
578
+ actions: Optional[List[str]] = None,
579
+ question_id: str | None = None,
580
+ headers: Dict[str, str] | None = None,
581
+ arguments: Dict[str, str] | None = None,
582
+ streaming: bool = False,
583
+ ):
584
+ self.session = session
585
+ self.started_at = datetime.now(timezone.utc)
586
+
587
+ # Start of a new question by the user
588
+ self.original_question = question
589
+ if actions is not None:
590
+ self.original_actions = actions
591
+ self.restart = True
592
+ if not question_id:
593
+ question_id = uuid4().hex
594
+ self.original_question_uuid = question_id
595
+
596
+ self.headers = headers if headers is not None else {}
597
+ self.arguments = arguments if arguments is not None else {}
598
+ self.streaming = streaming
599
+ self.contexts = []
600
+ self.steps = []
601
+
602
+ self.context_questions = OrderedDict()
603
+ self.original_actions = []
604
+ self.future_questions = OrderedDict()
605
+ self.answers: list[tuple[str, Optional[AnswerCitations]]] = []
606
+ self.generated_texts = {}
607
+ self.data_visualizations = []
608
+ self.final_answer_urls = []
609
+ self.agent_contexts = {}
610
+
611
+ self.set_actual_question(question, question_id)
612
+
613
+ def get_session_id(self) -> str:
614
+ """Returns the session ID for the current question. The session ID is a unique identifier that is shared across all questions and interactions that belong to the same session. This can be used to group related interactions together, and to keep track of the conversation history in a coherent way."""
615
+ return self.session.id
616
+
617
+ def get_agent_id(self) -> str:
618
+ """Returns the agent ID for the current question. The agent ID is a unique identifier that is shared across all questions and interactions that belong to the same agent. This can be used to group related interactions together, and to keep track of the conversation history in a coherent way."""
619
+ return self.session.agent_id
620
+
621
+ def get_workflow_id(self) -> str:
622
+ """Returns the workflow ID for the current question. The workflow ID is a unique identifier that is shared across all questions and interactions that belong to the same workflow. This can be used to group related interactions together, and to keep track of the conversation history in a coherent way."""
623
+ return self.session.workflow_id
624
+
625
+ def context_user_info(self) -> str:
626
+ """Returns a string with user information that can be used in the context of the agent. This can include information such as user preferences, user history, or any other relevant information about the user that can help the agent to generate a more personalized and accurate response."""
627
+ return self.session.context_user_info()
628
+
629
+ def get_rules(self) -> list[Rule | str]:
630
+ """Returns the rules for the current question. The rules are a unique identifier that is shared across all questions and interactions that belong to the same set of rules. This can be used to group related interactions together, and to keep track of the conversation history in a coherent way."""
631
+ return self.session.rules
632
+
633
+ async def search_in_questions(
634
+ self, question: str, all: bool = False
635
+ ) -> KnowledgeboxFindResults:
636
+ """Searches for similar questions in the conversation history. This can be used to find relevant information that has been previously discussed in the conversation, and to provide a more accurate and personalized response."""
637
+ return await self.session.search_in_questions(question, all)
638
+
639
+ def user_info(self) -> Dict[str, str]:
640
+ """Returns a string with user information that can be used in the context of the agent. This can include information such as user preferences, user history, or any other relevant information about the user that can help the agent to generate a more personalized and accurate response."""
641
+ return self.session.user_info
642
+
643
+ async def set_session_source(self, source: Source):
644
+ """Sets the source of the session. This can be used to keep track of where the information in the conversation is coming from, and to provide more context to the agent when generating a response."""
645
+ return await self.session.set_source(source)
646
+
647
+ async def get_session_source(self, source_id: str) -> Optional[Source]:
648
+ """Gets the source of the session. This can be used to keep track of where the information in the conversation is coming from, and to provide more context to the agent when generating a response."""
649
+ return await self.session.get_source(source_id)
650
+
651
+ async def context_history(self) -> Tuple[str, int]:
652
+ """Returns a string with the context history of the conversation. This can include information such as previous questions and answers, relevant information that has been previously discussed in the conversation, or any other relevant information that can help the agent to generate a more accurate and personalized response."""
653
+ return await self.session.context_history()
654
+
655
+ async def get_chat_history(self) -> list[Message]:
656
+ """Returns a list of tuples with the chat history of the conversation. Each tuple contains a question and an answer. This can be used to keep track of the conversation history in a more structured way, and to provide more context to the agent when generating a response."""
657
+ return await self.session.get_chat_history()
658
+
659
+ def stats(self):
660
+ return {
661
+ "session": self.session,
662
+ "contexts": [context.stats() for context in self.contexts],
663
+ "context_questions": self.context_questions,
664
+ "original_question": self.original_question,
665
+ "original_actions": self.original_actions,
666
+ "final_answer": self.final_answer,
667
+ }
668
+
669
+ async def save_context(self, flow_id: str, context: Context):
670
+ context.original_question_uuid = self.original_question_uuid
671
+ context.actual_question_uuid = self.actual_question_uuid
672
+ self.contexts.append(context)
673
+ if self.agent_contexts.get(flow_id) is None:
674
+ self.agent_contexts[flow_id] = {}
675
+ if self.agent_contexts[flow_id].get(context.agent_id) is None:
676
+ self.agent_contexts[flow_id][context.agent_id] = []
677
+ self.agent_contexts[flow_id][context.agent_id].append(context)
678
+ if self.callback_fn is not None:
679
+ await self.callback_fn(AragAnswer(context=context))
680
+
681
+ async def save_image_urls(self, image_urls: List[str]):
682
+ # We need to extend but deduplicate bedore extending, taking into account that we only check the part before ?eph-token
683
+ # TODO: in the next iteration we should handle this better, i.e. savin the token separately
684
+ existing_urls = {url.split("?eph-token")[0] for url in self.final_answer_urls}
685
+ new_urls = [
686
+ url for url in image_urls if url.split("?eph-token")[0] not in existing_urls
687
+ ]
688
+ self.final_answer_urls.extend(new_urls)
689
+
690
+ def get_agent_contexts(self, flow_id: str, agent_id: str) -> List[Context]:
691
+ flow_contexts = self.agent_contexts.get(flow_id, {})
692
+ return flow_contexts.get(agent_id, [])
693
+
694
+ def get_agent_answer_summaries(self, flow_id: str, agent_id: str) -> List[str]:
695
+ flow_contexts = self.agent_contexts.get(flow_id, {})
696
+ contexts = flow_contexts.get(agent_id, [])
697
+ return [
698
+ context.summary for context in contexts if context.summary.strip() != ""
699
+ ]
700
+
701
+ def list_contexts_markdown(self) -> list[str]:
702
+ contexts_str = []
703
+ for context in self.contexts:
704
+ result = ""
705
+ if context.citations_id is not None:
706
+ if context.title:
707
+ result += f"## [{context.citations_id}] {context.title}\n\n"
708
+ else:
709
+ result += f"## [{context.citations_id}]\n\n"
710
+ else:
711
+ if context.title:
712
+ result += f"## {context.title}\n\n"
713
+ result += f"{context.context_markdown()}"
714
+ contexts_str.append(result)
715
+ return contexts_str
716
+
717
+ def list_chunks_markdown(self) -> list[str]:
718
+ chunks_str = []
719
+ for context in self.contexts:
720
+ for chunk in context.chunks:
721
+ result = ""
722
+ if context.citations_id is not None:
723
+ if chunk.title:
724
+ result += f"## [{context.citations_id}] {chunk.title}\n\n"
725
+ else:
726
+ result += f"## [{context.citations_id}]\n\n"
727
+ else:
728
+ if chunk.title:
729
+ result += f"## {chunk.title}\n\n"
730
+ result += f"{chunk.text}\n\n"
731
+ chunks_str.append(result)
732
+ return chunks_str
733
+
734
+ def contexts_markdown(self) -> str:
735
+ """
736
+ Returns the concatenated contexts as a single string. Includes full context (i.e: all the chunk texts)
737
+ """
738
+ return "\n\n".join(self.list_contexts_markdown())
739
+
740
+ def list_contexts_minimal(
741
+ self,
742
+ ) -> list[str]:
743
+ contexts_str = []
744
+ for context in self.contexts:
745
+ result = ""
746
+ if context.citations_id is not None:
747
+ if context.title:
748
+ result += f"## [{context.citations_id}] {context.title}\n\n"
749
+ else:
750
+ result += f"## [{context.citations_id}]\n\n"
751
+ else:
752
+ if context.title:
753
+ result += f"## {context.title}\n\n"
754
+ if context.summary.strip() != "":
755
+ result += f"{context.answer_summary_markdown()}"
756
+ else:
757
+ result += f"{context.context_markdown()}"
758
+ contexts_str.append(result)
759
+ return contexts_str
760
+
761
+ def contexts_minimal(self) -> str:
762
+ """
763
+ Returns the concatenated minimal contexts as a single string.
764
+ Minimal contexts include summaries if available, otherwise full context (i.e: the chunk texts)
765
+ """
766
+ return "\n\n".join(self.list_contexts_minimal())
767
+
768
+ def get_prompt_texts(self) -> List[str]:
769
+ prompts = []
770
+ for context in self.contexts:
771
+ for prompt in context.prompts:
772
+ prompts.append(prompt.render())
773
+ return prompts
774
+
775
+ async def add_generated_text(self, generation_id: str, generated_text: str):
776
+ self.generated_texts[generation_id] = generated_text
777
+ if self.callback_fn is not None:
778
+ await self.callback_fn(AragAnswer(generated_text=generated_text))
779
+
780
+ async def add_step(
781
+ self,
782
+ step_module: str,
783
+ step_title: str,
784
+ step_agent_path: str,
785
+ timeit: float,
786
+ input_nuclia_tokens: Optional[float] = None,
787
+ output_nuclia_tokens: Optional[float] = None,
788
+ step_value: Optional[str] = None,
789
+ step_reason: Optional[str] = None,
790
+ error: Optional[str] = None,
791
+ ):
792
+ new_step = Step(
793
+ original_question_uuid=self.original_question_uuid,
794
+ actual_question_uuid=self.actual_question_uuid,
795
+ module=step_module,
796
+ title=step_title,
797
+ agent_path=step_agent_path,
798
+ value=step_value,
799
+ reason=step_reason,
800
+ timeit=timeit,
801
+ input_nuclia_tokens=input_nuclia_tokens,
802
+ output_nuclia_tokens=output_nuclia_tokens,
803
+ error=error,
804
+ )
805
+ self.steps.append(new_step)
806
+ if self.callback_fn is not None:
807
+ await self.callback_fn(AragAnswer(step=new_step))
808
+
809
+ def set_actual_question(self, question: str, uuid: Optional[str] = None):
810
+ self.actual_question = question
811
+ if uuid is None:
812
+ uuid = uuid4().hex
813
+ self.actual_question_uuid = uuid
814
+ # We dont need to set the question in the context
815
+ # This makes the original question to be added to the context questions
816
+ # self._set_question(question, uuid)
817
+
818
+ def _set_question(self, question: str, uuid: str):
819
+ self.context_questions[uuid] = question
820
+
821
+ def add_future_questions(self, questions: List[str]):
822
+ for question in questions:
823
+ self.future_questions[uuid4().hex] = question
824
+
825
+ def add_context_questions(self, questions: List[str]):
826
+ for question in questions:
827
+ self.context_questions[uuid4().hex] = question
828
+
829
+ def get_questions(self) -> List[Tuple[str, str]]:
830
+ """Returns context questions if they exist, otherwise returns the original question."""
831
+ if len(self.context_questions) > 0:
832
+ return list(self.context_questions.items())
833
+ return [(self.original_question_uuid, self.original_question)]
834
+
835
+ async def add_answer(
836
+ self,
837
+ answer: str,
838
+ module: str,
839
+ agent_path: str,
840
+ citations: Optional[AnswerCitations] = None,
841
+ visualization: Visualization | None = None,
842
+ chunks: Optional[list[Chunk]] = None,
843
+ structured: Optional[list[str]] = None,
844
+ images: Optional[Dict[str, Image]] = None,
845
+ image_urls: Optional[list[str]] = None,
846
+ ):
847
+ answer_obj = Answer(
848
+ answer=answer,
849
+ module=module,
850
+ agent_path=agent_path,
851
+ original_question_uuid=self.original_question_uuid,
852
+ actual_question_uuid=self.actual_question_uuid,
853
+ citations=citations,
854
+ chunks=chunks,
855
+ data_visualizations=[visualization] if visualization else None,
856
+ structured=structured,
857
+ images=images,
858
+ image_urls=image_urls,
859
+ )
860
+ self.answers.append((answer, citations))
861
+ if visualization is not None:
862
+ self.data_visualizations.append(visualization)
863
+
864
+ if self.callback_fn is not None:
865
+ answer_obj_to_send = AragAnswer(
866
+ possible_answer=answer_obj,
867
+ )
868
+ await self.callback_fn(answer_obj_to_send)
869
+
870
+ async def add_final_answer(self):
871
+ if len(self.answers) == 0:
872
+ logger.info("No answers found")
873
+ return
874
+ else:
875
+ answer, citations = self.answers[-1]
876
+ self.final_answer = answer
877
+ self.final_answer_citations = citations
878
+
879
+ async def send_final_answer(self):
880
+ if len(self.answers) == 0:
881
+ logger.info("No answers to send")
882
+ return
883
+ else:
884
+ answer, citations = self.answers[-1]
885
+ self.final_answer = answer
886
+ answer_obj = AragAnswer(
887
+ answer=answer,
888
+ original_question_uuid=self.original_question_uuid,
889
+ answer_citations=citations,
890
+ )
891
+ if self.callback_fn is not None:
892
+ await self.callback_fn(answer_obj)
893
+
894
+ def show_intermediate_steps(self):
895
+ pass
896
+
897
+ async def send_oauth(self, oauth: OAuthAuthenticateURL) -> None:
898
+ if self.oauth_fn is not None:
899
+ return await self.oauth_fn(oauth)
900
+ return None
901
+
902
+ async def send_feedback(self, feedback: Feedback) -> UserToAgentInteraction | None:
903
+ if self.feedback_fn is not None:
904
+ return await self.feedback_fn(feedback)
905
+ return None
906
+
907
+ async def recv_oauth_callback(
908
+ self, question_id: str, oauth_uuid: str
909
+ ) -> str | None:
910
+ """Receive OAuth callback credentials."""
911
+ if self.oauth_callback_fn is not None:
912
+ return await self.oauth_callback_fn(question_id, oauth_uuid)
913
+ return None
914
+
915
+ def set_callback_fn(self, callback: Callable[[AragAnswer], Awaitable[None]]):
916
+ self.callback_fn = callback
917
+
918
+ async def emit_streaming_chunk(
919
+ self,
920
+ chunk: StreamingChunk | None = None,
921
+ *,
922
+ reasoning: bool = False,
923
+ agent_request: str | None = None,
924
+ ) -> None:
925
+ """Emit any streaming event through the callback."""
926
+ if self.callback_fn is None:
927
+ return
928
+
929
+ answer = AragAnswer(
930
+ operation=AnswerOperation.REASONING
931
+ if reasoning
932
+ else AnswerOperation.ANSWER_CHUNK,
933
+ )
934
+ if agent_request is not None:
935
+ answer.agent_request = agent_request
936
+ if chunk is not None:
937
+ if reasoning:
938
+ answer.reasoning = chunk
939
+ else:
940
+ answer.streaming_response_chunk = chunk
941
+ await self.callback_fn(answer)
942
+
943
+ def set_oauth_fn(self, oauth: Callable[[OAuthAuthenticateURL], Awaitable[None]]):
944
+ self.oauth_fn = oauth
945
+
946
+ def set_oauth_callback_fn(
947
+ self,
948
+ oauth_callback: Callable[[str, str], Awaitable[str | None]],
949
+ ):
950
+ self.oauth_callback_fn = oauth_callback
951
+
952
+ def set_feedback_fn(
953
+ self, feedback: Callable[[Feedback], Awaitable[UserToAgentInteraction | None]]
954
+ ):
955
+ self.feedback_fn = feedback
956
+
957
+ async def save(self):
958
+ await self.session.save(self)
959
+
960
+ def get_tracking_info(self) -> TrackingInfo:
961
+ """Returns tracking context for propagation to manager calls."""
962
+ return TrackingInfo(
963
+ rao_id=self.get_agent_id(),
964
+ session=self.get_tracking_session(),
965
+ message=self.original_question_uuid,
966
+ )
967
+
968
+ def get_tracking_session(self) -> str:
969
+ """Returns a composite session key combining workflow and session IDs for activity tracking."""
970
+ return f"{self.get_workflow_id()}_{self.get_session_id()}"
971
+
972
+ def get_streaming(self) -> bool:
973
+ """Returns whether streaming is enabled for this interaction."""
974
+ return self.streaming