Skip to content
Merged
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
113 changes: 52 additions & 61 deletions agents/smart/src/hyperforge_smart/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ def _reactive_tools(
def _process_results(
self,
results: List[Tuple[str, Any]],
context: Optional[Context] = None,
collected_contexts: Optional[List[Context]] = None,
) -> List[str]:
"""Process tool results: optionally update context chunks, always return text summaries.
"""Process tool results and optionally retain each returned context.

ToolError results are included in the text summaries (so the LLM
is aware of the failure) but are never stored in the context.
Expand Down Expand Up @@ -386,44 +386,52 @@ def _process_results(
if overflow is not None:
error_text = overflow.render()
result_texts.append(f"[{action_info}]:\n{error_text}")
if context is not None:
context.chunks.append(
Chunk(
chunk_id=uuid4().hex,
text=error_text,
action=action_info,
origin_agent=self.config.module,
)
if collected_contexts is not None:
collected_contexts.append(
self._synthetic_result_context(action_info, error_text)
)
continue

if context is not None:
if collected_contexts is not None:
if contexts:
for ctx in contexts:
for chunk in ctx.chunks:
chunk.action = action_info
context.chunks.append(chunk)
if ctx.structured:
for structured in ctx.structured:
if structured:
context.structured.append(structured)
collected_contexts.append(ctx)
else:
context.chunks.append(
Chunk(
chunk_id=uuid4().hex,
text=str(result),
action=action_info,
origin_agent=self.config.module, # TODO: track origin agent in a better way for text results (this is a corner case, ideally tools return Context objects)
)
collected_contexts.append(
self._synthetic_result_context(action_info, str(result))
)

for ctx in contexts:
result_texts.append(f"[{action_info}]:\n{ctx.context_markdown()}")
if ctx.summary:
result_texts.append(f"[{action_info}]:\n{ctx.summary}")
else:
result_texts.append(f"[{action_info}]:\n{ctx.context_markdown()}")
if not contexts:
result_texts.append(f"[{action_info}]:\n{result}")

return result_texts

def _synthetic_result_context(self, action_info: str, text: str) -> Context:
return Context(
agent_id=self.config.id or "smart_agent",
original_question_uuid=None,
actual_question_uuid=None,
question="",
source="smart_agent",
agent="smart_agent",
title=action_info,
chunks=[
Chunk(
chunk_id=uuid4().hex,
text=text,
action=action_info,
origin_agent=self.config.module,
)
],
)

def _inspect_result(self, result: Any):
if isinstance(result, ToolError):
texts = [result.error]
Expand Down Expand Up @@ -628,7 +636,7 @@ async def smart_planner(
manager: Manager,
question_uuid: Optional[str] = None,
extra_context: Optional[Dict[str, Any]] = None,
) -> Context:
) -> List[Context]:
"""Entry point: dispatches to the appropriate reasoning mode."""
if question_uuid is None:
question_uuid = uuid4().hex
Expand Down Expand Up @@ -692,7 +700,7 @@ async def _execute_tool_calls_turn(
messages: List[Message],
tool_calls: List[Tuple[str, Any]],
turn_label: str,
context: Optional[Context] = None,
collected_contexts: Optional[List[Context]] = None,
attempted_tool_calls: Optional[Dict[str, ToolAttempt]] = None,
) -> List[Tuple[str, Any]]:
"""Handle one turn of tool calls.
Expand Down Expand Up @@ -751,7 +759,7 @@ async def _execute_tool_calls_turn(
feedback_text,
)
result_texts = self._process_results(
[feedback_result], context=context
[feedback_result], collected_contexts=collected_contexts
)
if result_texts:
messages.append(
Expand Down Expand Up @@ -832,7 +840,9 @@ async def _execute_tool_calls_turn(
if previous_attempt is not None:
previous_attempt.detail = str(skipped_result)

result_texts = self._process_results(list(results), context=context)
result_texts = self._process_results(
list(results), collected_contexts=collected_contexts
)
result_summary = "; ".join(
f"{info}: {'context' if isinstance(res, Context) else type(res).__name__}"
for info, res in results
Expand Down Expand Up @@ -860,7 +870,7 @@ async def _reactive_loop(
extra_context: Optional[Dict[str, Any]] = None,
session_context: str = "",
history_messages: Optional[List[Message]] = None,
) -> Context:
) -> List[Context]:
t0 = time()

tools = self.build_tools()
Expand All @@ -877,15 +887,7 @@ async def _reactive_loop(
)
messages.append(Message(author=Author.USER, text=question))

context = Context(
agent_id=self.config.id or "smart_agent",
original_question_uuid=memory.original_question_uuid,
actual_question_uuid=question_uuid,
question=question,
source="smart_agent",
agent="smart_agent",
title=self.config.title or "Smart Agent Results",
)
contexts: List[Context] = []

iteration = 0
finished = False
Expand Down Expand Up @@ -929,12 +931,7 @@ async def _reactive_loop(
output_nuclia_tokens=output_tokens,
)

if not tool_calls:
finished = True
break

# Check for task_complete before executing
if any(name == "task_complete" for name, _ in tool_calls):
if not tool_calls or any(name == "task_complete" for name, _ in tool_calls):
finished = True
break

Expand All @@ -945,7 +942,7 @@ async def _reactive_loop(
messages=messages,
tool_calls=tool_calls,
turn_label=f"iteration {iteration}/{self.config.max_iterations}",
context=context,
collected_contexts=contexts,
attempted_tool_calls=attempted_tool_calls,
)

Expand All @@ -965,7 +962,7 @@ async def _reactive_loop(
output_nuclia_tokens=total_output_tokens,
)

return context
return contexts

async def _call_planner(
self,
Expand Down Expand Up @@ -1086,20 +1083,12 @@ async def _plan_and_execute(
question_uuid: str,
extra_context: Optional[Dict[str, Any]] = None,
session_context: str = "",
) -> Context:
) -> List[Context]:
"""Plan-execute reasoning mode: planner drafts a plan, executor runs tools, repeat."""
t0 = time()
agent_path = f"/context/{self.config.id or 'default'}"

context = Context(
agent_id=self.config.id or "smart_agent",
original_question_uuid=memory.original_question_uuid,
actual_question_uuid=question_uuid,
question=question,
source="smart_agent",
agent="smart_agent",
title=self.config.title or "Smart Agent Results",
)
contexts: List[Context] = []

history: List[PlanIteration] = []
iteration = 0
Expand Down Expand Up @@ -1181,7 +1170,9 @@ async def _plan_and_execute(
total_input_tokens += exec_in_tokens
total_output_tokens += exec_out_tokens

result_texts = self._process_results(iteration_results, context=context)
result_texts = self._process_results(
iteration_results, collected_contexts=contexts
)
results_summary = (
"\n\n".join(result_texts) if result_texts else "(no results)"
)
Expand Down Expand Up @@ -1231,7 +1222,7 @@ async def _plan_and_execute(
output_nuclia_tokens=total_output_tokens,
)

return context
return contexts

async def _get_question_context(
self,
Expand All @@ -1242,16 +1233,16 @@ async def _get_question_context(
flow_id: str,
extra_context: Optional[Dict[str, Any]] = None,
) -> List[Tuple[str, str]]:
context = await self.smart_planner(
contexts = await self.smart_planner(
memory=memory,
manager=manager,
question_uuid=question_uuid,
question=question,
extra_context=extra_context,
)

missing = await self.save_ctx_and_return_missing(
context=context,
missing = await self.save_contexts_and_return_missing(
contexts=contexts,
question=question,
memory=memory,
manager=manager,
Expand Down
56 changes: 56 additions & 0 deletions agents/smart/tests/test_contexts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from hyperforge.models import Chunk, Context

from hyperforge_smart.agent import SmartAgent
from hyperforge_smart.config import SmartAgentConfig


def make_context(
agent_id: str,
*,
summary: str = "",
chunks: list[str] | None = None,
structured: list[str] | None = None,
) -> Context:
return Context(
agent_id=agent_id,
original_question_uuid="original",
actual_question_uuid="actual",
question="question",
source=agent_id,
agent=agent_id,
title=agent_id,
summary=summary,
chunks=[
Chunk(chunk_id=f"{agent_id}-{index}", text=text)
for index, text in enumerate(chunks or [])
],
structured=structured or [],
)


def make_agent() -> SmartAgent:
return SmartAgent(
SmartAgentConfig(
module="smart",
id="smart",
registered_agents=[],
)
)


def test_process_results_preserves_original_contexts() -> None:
agent = make_agent()
first = make_context("first", summary="First answer", chunks=["first chunk"])
second = make_context("second", chunks=["second chunk"])
collected: list[Context] = []

result_texts = agent._process_results(
[("ask_agent of nucliadb", [first, second])],
collected_contexts=collected,
)

assert collected == [first, second]
assert collected[0].summary == "First answer"
assert collected[0].source == "first"
assert collected[0].chunks[0].action == "ask_agent of nucliadb"
assert result_texts[0] == "[ask_agent of nucliadb]:\nFirst answer"
18 changes: 7 additions & 11 deletions agents/smart/tests/test_result_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,18 @@ def test_process_results_replaces_an_oversized_context_with_retry_guidance():
agent="test",
chunks=[Chunk(chunk_id="large", text="x" * 100)],
)
accumulated = Context(
original_question_uuid=None,
actual_question_uuid=None,
question="question",
source="smart",
agent="smart",
)
collected_contexts: list[Context] = []

texts = agent._process_results([("tool of test", result)], context=accumulated)
texts = agent._process_results(
[("tool of test", result)], collected_contexts=collected_contexts
)

assert len(texts) == 1
assert "safety budget" in texts[0]
assert "x" * 100 not in texts[0]
assert len(accumulated.chunks) == 1
assert "safety budget" in accumulated.chunks[0].text
assert accumulated.chunks[0].text != result.chunks[0].text
assert len(collected_contexts) == 1
assert "safety budget" in collected_contexts[0].chunks[0].text
assert collected_contexts[0].chunks[0].text != result.chunks[0].text


def test_process_results_replaces_an_oversized_tool_error():
Expand Down
10 changes: 6 additions & 4 deletions agents/smart/tests/test_smart_mcp_perplexity.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async def test_smart_with_mcp_and_perplexity(
question = "Retrieve the document about the architecture of Agents and give me info about the authors"
question_memory = memory.start_question(question, question_id="question_id")

context = await smart_agent.smart_planner(
contexts = await smart_agent.smart_planner(
question=question,
memory=question_memory,
manager=manager,
Expand All @@ -161,8 +161,8 @@ async def test_smart_with_mcp_and_perplexity(
assert "internet_search" in all_step_values

# Verify contexts were gathered
assert context, "SmartAgent should have collected context"
total_chunks = len(context.chunks)
assert contexts, "SmartAgent should have collected context"
total_chunks = sum(len(context.chunks) for context in contexts)
assert total_chunks > 0, "Should have retrieved document chunks via MCP"

# Verify document content was retrieved
Expand All @@ -171,7 +171,9 @@ async def test_smart_with_mcp_and_perplexity(
"Agentic Context Engineering",
"Philipp SchmidHugging Face",
]
all_chunk_text = " ".join(chunk.text for chunk in context.chunks)
all_chunk_text = " ".join(
chunk.text for context in contexts for chunk in context.chunks
)
assert any(text in all_chunk_text for text in expected_texts), (
f"Expected document content not found in retrieved chunks. "
f"Got: {all_chunk_text[:300]}"
Expand Down
Loading
Loading