Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion hyperforge/src/hyperforge/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BaseModel, Field

from hyperforge.driver import DriverConfig
from hyperforge.models import Rules
from hyperforge.models import HistoryQuestionAnswer, Rules


class StashRoles(str, Enum):
Expand Down Expand Up @@ -125,5 +125,9 @@ class InteractionRequest(BaseModel):
question: str
headers: Dict[str, str] = {}
arguments: Dict[str, str] = {}
chat_history: Optional[List[HistoryQuestionAnswer]] = Field(
default=None,
description="Client-managed chat history. When set (even to an empty list), overrides any server-side session history for agents that use previous Q&A context (rephrase, summarize, smart, etc.). Omit the field entirely to use server-side session history.",
)
operation: InteractionOperation = InteractionOperation.QUESTION
streaming: bool = False
1 change: 1 addition & 0 deletions hyperforge/src/hyperforge/api/v1/interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ async def stream_response(
question=interaction.question,
headers=interaction.headers,
arguments=interaction.arguments,
chat_history=interaction.chat_history,
workflow_id=workflow_id,
streaming=interaction.streaming,
)
Expand Down
8 changes: 6 additions & 2 deletions hyperforge/src/hyperforge/engine.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, cast
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, cast

from nuclia.lib.nua import AsyncNuaClient

Expand All @@ -9,6 +9,7 @@
from hyperforge.llm import NoopNuaClient, NuaBaseModel, NUAConnection
from hyperforge.manager import Manager
from hyperforge.memory.memory import BaseSessionMemory, QuestionMemory, SessionMemory
from hyperforge.models import HistoryQuestionAnswer
from hyperforge.retrieval.agent import RetrievalAgent
from hyperforge.retrieval.config import RetrievalAgentConfig

Expand Down Expand Up @@ -84,6 +85,7 @@ async def main(
headers: Optional[Dict[str, str]] = None,
memory_klass: type[BaseSessionMemory] = SessionMemory,
streaming: bool = False,
chat_history: Optional[List[HistoryQuestionAnswer]] = None,
) -> QuestionMemory:
try:
state, session_memory = await init(
Expand All @@ -99,7 +101,9 @@ async def main(
session_id=session_id,
memory_klass=memory_klass,
)
question_memory = session_memory.start_question(question, streaming=streaming)
question_memory = session_memory.start_question(
question, streaming=streaming, chat_history=chat_history
)
if callback is not None:
question_memory.set_callback_fn(callback)

Expand Down
74 changes: 49 additions & 25 deletions hyperforge/src/hyperforge/memory/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@

# NucliaDB storage
QUESTION_ANSWERS_FIELD: str = "qas"


def _qa_list_to_context_string(history: List[HistoryQuestionAnswer]) -> Tuple[str, int]:
"""Format a list of Q&A pairs into the prompt context string used by agents."""
result = "".join(
f"- Question: {qa.question}\n- Answer: {qa.answer}\n" for qa in history
)
return result, len(history)


def _qa_list_to_chat_messages(history: List[HistoryQuestionAnswer]) -> List[Message]:
"""Convert a list of Q&A pairs into the alternating User/Nuclia Message list used by LLMs."""
return [
msg
for qa in history
for msg in (
Message(author=Author.USER, text=qa.question),
Message(author=Author.NUCLIA, text=qa.answer),
)
]
CONTEXT_FIELD: str = "context"
STEPS_FIELD: str = "steps"
USER_INFO_FIELD: str = "user_info"
Expand Down Expand Up @@ -112,34 +132,13 @@ async def search_in_questions(self, question: str, all: bool):
return KnowledgeboxFindResults(total=0, resources={})

async def get_chat_history(self) -> List[Message]:
qas = await self.qa_history()
result = []
for qa in qas:
result.append(
Message(
author=Author.USER,
text=qa.question,
)
)
result.append(
Message(
author=Author.NUCLIA,
text=qa.answer,
)
)
return result
return _qa_list_to_chat_messages(await self.qa_history())

async def qa_history(self) -> list[HistoryQuestionAnswer]:
return []

async def context_history(self) -> Tuple[str, int]:
result = ""
interactions = 0
for qa in await self.qa_history():
result += f"- Question: {qa.question}\n"
result += f"- Answer: {qa.answer}\n"
interactions += 1
return result, interactions
return _qa_list_to_context_string(await self.qa_history())

def start_question(
self,
Expand All @@ -149,6 +148,7 @@ def start_question(
headers: Dict[str, str] = {},
arguments: Dict[str, str] = {},
streaming: bool = False,
chat_history: Optional[List[HistoryQuestionAnswer]] = None,
) -> "QuestionMemory":
return QuestionMemory(
self,
Expand All @@ -158,6 +158,7 @@ def start_question(
headers=headers,
arguments=arguments,
streaming=streaming,
chat_history=chat_history,
)

async def save(self, question: "QuestionMemory") -> None:
Expand All @@ -183,6 +184,7 @@ def start_question(
headers: Dict[str, str] = {},
arguments: Dict[str, str] = {},
streaming: bool = False,
chat_history: Optional[List[HistoryQuestionAnswer]] = None,
) -> "QuestionMemory":
return QuestionMemory(
self,
Expand All @@ -192,6 +194,7 @@ def start_question(
headers=headers,
arguments=arguments,
streaming=streaming,
chat_history=chat_history,
)

async def save(self, question: "QuestionMemory") -> None:
Expand Down Expand Up @@ -250,6 +253,7 @@ def start_question(
headers: Dict[str, str] = {},
arguments: Dict[str, str] = {},
streaming: bool = False,
chat_history: Optional[List[HistoryQuestionAnswer]] = None,
) -> "QuestionMemory":
return QuestionMemory(
self,
Expand All @@ -259,6 +263,7 @@ def start_question(
headers=headers,
arguments=arguments,
streaming=streaming,
chat_history=chat_history,
)

async def save(self, question: "QuestionMemory") -> None:
Expand Down Expand Up @@ -580,10 +585,21 @@ def __init__(
headers: Dict[str, str] | None = None,
arguments: Dict[str, str] | None = None,
streaming: bool = False,
chat_history: Optional[List[HistoryQuestionAnswer]] = None,
):
self.session = session
self.started_at = datetime.now(timezone.utc)

# Client-managed chat history. When set (even to an empty list), overrides
# server-side session history for agents that use previous Q&A context
# (rephrase, summarize, smart, etc.). None means "not set — use server-side
# history". [] means "override with no history". Intended for ephemeral
# sessions where the client is responsible for maintaining conversation state.
# Note: search_in_questions() performs semantic search over NucliaDB-stored
# conversation history and is NOT affected by this field. The HistoricalAgent
# uses that method and therefore does not benefit from client-managed history.
self._client_chat_history: Optional[List[HistoryQuestionAnswer]] = chat_history

# Start of a new question by the user
self.original_question = question
if actions is not None:
Expand Down Expand Up @@ -649,11 +665,19 @@ async def get_session_source(self, source_id: str) -> Optional[Source]:
return await self.session.get_source(source_id)

async def context_history(self) -> Tuple[str, int]:
"""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."""
"""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.

When the client sets chat_history in the request (even to an empty list), it overrides any server-side session history. None means "not set — use server-side history"."""
if self._client_chat_history is not None:
return _qa_list_to_context_string(self._client_chat_history)
return await self.session.context_history()

async def get_chat_history(self) -> list[Message]:
"""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."""
"""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.

When the client sets chat_history in the request (even to an empty list), it overrides any server-side session history. None means "not set — use server-side history"."""
if self._client_chat_history is not None:
return _qa_list_to_chat_messages(self._client_chat_history)
return await self.session.get_chat_history()

def stats(self):
Expand Down
7 changes: 6 additions & 1 deletion hyperforge/src/hyperforge/pubsub.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Annotated, Dict, Literal
from typing import Annotated, Dict, List, Literal, Optional

from pydantic import AliasChoices, BaseModel, Field
from pydantic.types import Discriminator, Tag
Expand All @@ -8,6 +8,7 @@
Feedback,
OAuthAuthenticateURL,
)
from hyperforge.models import HistoryQuestionAnswer

# Messages used in the pubsub protocol between API and agent servers

Expand All @@ -22,6 +23,10 @@ class StartInteraction(BaseModel):
question: str
headers: Dict[str, str] = {}
arguments: Dict[str, str] = {}
chat_history: Optional[List[HistoryQuestionAnswer]] = Field(
default=None,
description="Client-managed chat history. When set (even to an empty list), overrides any server-side session history for agents that use previous Q&A context. Omit the field entirely to use server-side session history.",
)
workflow_id: str = "default"
streaming: bool = False
op: Literal["start"] = "start"
Expand Down
1 change: 1 addition & 0 deletions hyperforge/src/hyperforge/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ async def activate(self, message: StartInteraction):
headers=message.headers,
arguments=message.arguments,
streaming=message.streaming,
chat_history=message.chat_history,
)

task = asyncio.create_task(
Expand Down
Loading
Loading