Skip to content

Commit ed76d2f

Browse files
authored
Misc improvements (#179)
* openwebui stream better escaping * fix dialog agent finish msg rendering * ResearchDialogAgent was not included
1 parent d2386f7 commit ed76d2f

File tree

4 files changed

+98
-3
lines changed

4 files changed

+98
-3
lines changed

examples/sgr_deep_research/agents.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from openai import AsyncOpenAI, pydantic_function_tool
1111
from openai.types.chat import ChatCompletionFunctionToolParam, ChatCompletionMessageParam
1212

13+
from sgr_agent_core import AnswerTool
1314
from sgr_agent_core.agent_definition import AgentConfig
15+
from sgr_agent_core.agents.dialog_agent import DialogAgent
1416
from sgr_agent_core.agents.sgr_agent import SGRAgent
1517
from sgr_agent_core.agents.sgr_tool_calling_agent import SGRToolCallingAgent
1618
from sgr_agent_core.agents.tool_calling_agent import ToolCallingAgent
@@ -152,3 +154,50 @@ async def _prepare_tools(self) -> list[ChatCompletionFunctionToolParam]:
152154
WebSearchTool,
153155
}
154156
return [pydantic_function_tool(tool, name=tool.tool_name, description="") for tool in tools]
157+
158+
159+
class ResearchDialogAgent(DialogAgent):
160+
"""Dialog research agent: supports intermediate results (AnswerTool) and
161+
research toolkit with iteration/clarification/search limits.
162+
"""
163+
164+
def __init__(
165+
self,
166+
task_messages: list[ChatCompletionMessageParam],
167+
openai_client: AsyncOpenAI,
168+
agent_config: AgentConfig,
169+
toolkit: list[Type[BaseTool]],
170+
def_name: str | None = None,
171+
**kwargs: dict,
172+
):
173+
research_toolkit = [WebSearchTool, ExtractPageContentTool, CreateReportTool, AnswerTool]
174+
merged = research_toolkit + [t for t in toolkit if t not in research_toolkit]
175+
super().__init__(
176+
task_messages=task_messages,
177+
openai_client=openai_client,
178+
agent_config=agent_config,
179+
toolkit=merged,
180+
def_name=def_name,
181+
**kwargs,
182+
)
183+
184+
async def _prepare_tools(self) -> list[ChatCompletionFunctionToolParam]:
185+
"""Prepare available tools with research limits (iterations,
186+
clarifications, searches)."""
187+
tools = set(self.toolkit)
188+
if self._context.iteration >= self.config.execution.max_iterations:
189+
tools = {
190+
ReasoningTool,
191+
CreateReportTool,
192+
AnswerTool,
193+
}
194+
if self._context.clarifications_used >= self.config.execution.max_clarifications:
195+
tools -= {
196+
ClarificationTool,
197+
}
198+
search_config = self.get_tool_config(WebSearchTool)
199+
if self._context.searches_used >= search_config.max_searches:
200+
tools -= {
201+
WebSearchTool,
202+
}
203+
return [pydantic_function_tool(tool, name=tool.tool_name, description="") for tool in tools]

sgr_agent_core/agents/dialog_agent.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ async def _after_action_phase(self, action_tool: BaseTool, result: str) -> None:
6161
self._context.execution_result = result
6262
self.logger.info("\n⏸️ Research paused - please answer questions")
6363
self._context.state = AgentStatesEnum.WAITING_FOR_CLARIFICATION
64-
self.streaming_generator.finish()
64+
self.streaming_generator.finish(
65+
phase_id="{self._context.iteration}-final", content=self._context.execution_result
66+
)
6567
self._context.clarification_received.clear()
6668
await self._context.clarification_received.wait()
6769
return
@@ -70,6 +72,8 @@ async def _after_action_phase(self, action_tool: BaseTool, result: str) -> None:
7072
self._context.execution_result = result
7173
self.logger.info("\n💬 Dialog shared - agent waiting for response")
7274
self._context.state = AgentStatesEnum.WAITING_FOR_CLARIFICATION
73-
self.streaming_generator.finish(result)
75+
self.streaming_generator.finish(
76+
phase_id="{self._context.iteration}-final", content=self._context.execution_result
77+
)
7478
self._context.clarification_received.clear()
7579
await self._context.clarification_received.wait()

sgr_agent_core/stream.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import html
23
import json
34
import time
45
from typing import Any, AsyncGenerator
@@ -159,8 +160,9 @@ def _wrap_in_code_block(self, content: str, language: str = "") -> str:
159160
"""Wraps content in a Markdown code block."""
160161
if not content: # otherwise confusing placeholder will show
161162
return "{}"
163+
safe = html.escape(content, quote=False).replace("`", "\\`")
162164
lang_suffix = f" {language}" if language else ""
163-
return f"```{lang_suffix}\n{content}\n```"
165+
return f"```{lang_suffix}\n{safe}\n```"
164166

165167
def add_tool_call(self, phase_id: str, tool: BaseTool) -> None:
166168
"""Formats tool/reasoning and sends in <details>."""

tests/test_streaming.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,46 @@ async def test_add_tool_result_plain_wraps_in_code_block(self):
601601
assert "Tool Result: custom" in content
602602
assert "plain text result" in content
603603

604+
@pytest.mark.asyncio
605+
async def test_wrap_in_code_block_escapes_inner_backticks(self):
606+
"""Content with triple backticks is escaped so markdown block does not
607+
break."""
608+
generator = OpenWebUIStreamingGenerator(agent_id="test-id")
609+
report_with_code = "# Report\n\n```python\nprint(1)\n```\n"
610+
generator.add_tool_result(TEST_PHASE_ID, report_with_code, tool_name="CreateReport")
611+
generator.finish(TEST_PHASE_ID)
612+
613+
items = []
614+
async for item in generator.stream():
615+
items.append(item)
616+
617+
content_chunk = items[0]
618+
data = json.loads(content_chunk[6:].strip())
619+
content = data["choices"][0]["delta"]["content"]
620+
# Backticks in content are escaped so inner ``` do not close the block
621+
assert "\\`" in content
622+
assert "print(1)" in content
623+
624+
@pytest.mark.asyncio
625+
async def test_wrap_in_code_block_escapes_html(self):
626+
"""Content with </details> or </summary> is HTML-escaped so it does not
627+
break the page."""
628+
generator = OpenWebUIStreamingGenerator(agent_id="test-id")
629+
dangerous = "Summary: </summary> and </details> in text"
630+
generator.add_tool_result(TEST_PHASE_ID, dangerous, tool_name="Report")
631+
generator.finish(TEST_PHASE_ID)
632+
633+
items = []
634+
async for item in generator.stream():
635+
items.append(item)
636+
637+
content_chunk = items[0]
638+
data = json.loads(content_chunk[6:].strip())
639+
content = data["choices"][0]["delta"]["content"]
640+
assert "&lt;/summary&gt;" in content
641+
assert "&lt;/details&gt;" in content
642+
assert "<details>" in content # our real tag stays
643+
604644
@pytest.mark.asyncio
605645
async def test_finish_inherited_works(self):
606646
"""Test that finish() still produces [DONE] and final chunk."""

0 commit comments

Comments
 (0)