diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index ce001e24a..ce9e38f1b 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -8,6 +8,7 @@ import deerflow.utils.llm_text as llm_text from app.gateway.authz import require_permission from app.gateway.deps import get_config from deerflow.config.app_config import AppConfig +from deerflow.config.suggestions_config import DEFAULT_MAX_SUGGESTIONS, MAX_SUGGESTIONS_LIMIT from deerflow.utils.oneshot_llm import run_oneshot_llm logger = logging.getLogger(__name__) @@ -22,7 +23,7 @@ class SuggestionMessage(BaseModel): class SuggestionsRequest(BaseModel): messages: list[SuggestionMessage] = Field(..., description="Recent conversation messages") - n: int = Field(default=3, ge=1, le=5, description="Number of suggestions to generate") + n: int = Field(default=DEFAULT_MAX_SUGGESTIONS, ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Number of suggestions to generate") model_name: str | None = Field(default=None, description="Optional model override") @@ -32,6 +33,7 @@ class SuggestionsResponse(BaseModel): class SuggestionsConfigResponse(BaseModel): enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally") + max_suggestions: int = Field(..., ge=1, le=MAX_SUGGESTIONS_LIMIT, description="Maximum number of follow-up suggestions to generate") _strip_markdown_code_fence = llm_text.strip_markdown_code_fence @@ -76,6 +78,10 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str: return "\n".join(parts).strip() +def _configured_max_suggestions(config: AppConfig) -> int: + return getattr(config.suggestions, "max_suggestions", DEFAULT_MAX_SUGGESTIONS) + + @router.get( "/suggestions/config", response_model=SuggestionsConfigResponse, @@ -85,7 +91,7 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str: async def get_suggestions_config( config: AppConfig = Depends(get_config), ) -> SuggestionsConfigResponse: - return SuggestionsConfigResponse(enabled=config.suggestions.enabled) + return SuggestionsConfigResponse(enabled=config.suggestions.enabled, max_suggestions=_configured_max_suggestions(config)) @router.post( @@ -106,7 +112,7 @@ async def generate_suggestions( if not body.messages: return SuggestionsResponse(suggestions=[]) - n = body.n + n = min(body.n, _configured_max_suggestions(config)) conversation = _format_conversation(body.messages) if not conversation: return SuggestionsResponse(suggestions=[]) diff --git a/backend/packages/harness/deerflow/config/suggestions_config.py b/backend/packages/harness/deerflow/config/suggestions_config.py index a7b8817bd..641a1de2b 100644 --- a/backend/packages/harness/deerflow/config/suggestions_config.py +++ b/backend/packages/harness/deerflow/config/suggestions_config.py @@ -1,7 +1,16 @@ from pydantic import BaseModel, Field +DEFAULT_MAX_SUGGESTIONS = 3 +MAX_SUGGESTIONS_LIMIT = 5 + class SuggestionsConfig(BaseModel): """Configuration for automatic follow-up suggestions.""" enabled: bool = Field(default=True, description="Whether to enable follow-up question suggestions at the end of an AI response") + max_suggestions: int = Field( + default=DEFAULT_MAX_SUGGESTIONS, + ge=1, + le=MAX_SUGGESTIONS_LIMIT, + description="Maximum number of follow-up suggestions to generate.", + ) diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index b50bae597..30a69a7a6 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -125,6 +125,31 @@ def test_generate_suggestions_parses_and_limits(monkeypatch): assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "suggest_agent"} +def test_generate_suggestions_respects_configured_max(monkeypatch): + req = suggestions.SuggestionsRequest( + messages=[ + suggestions.SuggestionMessage(role="user", content="Hi"), + suggestions.SuggestionMessage(role="assistant", content="Hello"), + ], + n=4, + model_name=None, + ) + fake_model = MagicMock() + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='["Q1", "Q2", "Q3", "Q4"]')) + monkeypatch.setattr(oneshot_llm, "create_chat_model", lambda **kwargs: fake_model) + + result = asyncio.run( + suggestions.generate_suggestions.__wrapped__( + "t1", + req, + request=None, + config=SimpleNamespace(suggestions=SimpleNamespace(enabled=True, max_suggestions=2)), + ) + ) + + assert result.suggestions == ["Q1", "Q2"] + + def test_generate_suggestions_injects_deerflow_trace_metadata_when_langfuse_enabled(monkeypatch): monkeypatch.setenv("LANGFUSE_TRACING", "true") monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test") @@ -248,8 +273,10 @@ def test_get_suggestions_config(): mock_config_true = SimpleNamespace(suggestions=SimpleNamespace(enabled=True)) result_true = asyncio.run(suggestions.get_suggestions_config(config=mock_config_true)) assert result_true.enabled is True + assert result_true.max_suggestions == 3 # Test when disabled - mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False)) + mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False, max_suggestions=4)) result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false)) assert result_false.enabled is False + assert result_false.max_suggestions == 4 diff --git a/config.example.yaml b/config.example.yaml index 95d3a1f40..505144eb9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1050,6 +1050,7 @@ tool_output: suggestions: enabled: true + max_suggestions: 3 # ============================================================================ diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 7d122fc47..fc3678b39 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -81,6 +81,7 @@ import { type SidecarContext, } from "@/core/sidecar"; import { useSkills } from "@/core/skills/hooks"; +import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext, GoalState } from "@/core/threads"; import { compactThreadContext } from "@/core/threads/api"; @@ -398,6 +399,8 @@ export function InputBox({ const { data: suggestionsConfig } = useSuggestionsConfig(); const suggestionsConfigLoaded = suggestionsConfig !== undefined; const suggestionsEnabled = suggestionsConfig?.enabled; + const maxFollowupSuggestions = + suggestionsConfig?.max_suggestions ?? DEFAULT_MAX_SUGGESTIONS; const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const [polishingInput, setPolishingInput] = useState(false); @@ -1998,7 +2001,7 @@ export function InputBox({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: recent, - n: 3, + n: maxFollowupSuggestions, model_name: context.model_name ?? undefined, }), signal: controller.signal, @@ -2013,7 +2016,7 @@ export function InputBox({ const suggestions = (data.suggestions ?? []) .map((s) => (typeof s === "string" ? s.trim() : "")) .filter((s) => s.length > 0) - .slice(0, 5); + .slice(0, maxFollowupSuggestions); setFollowups(suggestions); }) .catch(() => { @@ -2028,6 +2031,7 @@ export function InputBox({ context.model_name, disabled, isMock, + maxFollowupSuggestions, status, suggestionsConfigLoaded, suggestionsEnabled, diff --git a/frontend/src/core/suggestions/api.ts b/frontend/src/core/suggestions/api.ts index 05ca7ca68..878f045d8 100644 --- a/frontend/src/core/suggestions/api.ts +++ b/frontend/src/core/suggestions/api.ts @@ -1,8 +1,11 @@ import { fetch } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; +export const DEFAULT_MAX_SUGGESTIONS = 3; + export interface SuggestionsConfigResponse { enabled: boolean; + max_suggestions: number; } export async function loadSuggestionsConfig(): Promise { @@ -10,7 +13,7 @@ export async function loadSuggestionsConfig(): Promise