Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions backend/app/model/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ class QuestionAnalysisResult(BaseModel):
)


class TaskAnalysisResult(BaseModel):
"""Result of combined task analysis (complexity + summary).

Merges question_confirm (complexity) and summary_task (name/summary)
into one LLM call. See issue #1427.
"""

is_complex: bool = Field(
description="True if complex task requiring tools/workforce, "
"False if simple question answerable directly."
)
task_name: str | None = Field(
default=None,
description="Short descriptive task name. Only when is_complex=True.",
)
summary: str | None = Field(
default=None,
description="Concise task summary. Only when is_complex=True.",
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can add some post validation or postprocess that when is_complex=True task_name and summary need to be None.
Also cc @Wendong-Fan IMO we should still have a task_name when it's a non complex chat?



McpServers = dict[Literal["mcpServers"], dict[str, dict]]


Expand Down
226 changes: 174 additions & 52 deletions backend/app/service/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import asyncio
import datetime
import json
import logging
import platform
from pathlib import Path
Expand Down Expand Up @@ -43,7 +44,14 @@
from app.agent.toolkit.skill_toolkit import SkillToolkit
from app.agent.toolkit.terminal_toolkit import TerminalToolkit
from app.agent.tools import get_mcp_tools, get_toolkits
from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json
from app.model.chat import (
Chat,
NewAgent,
Status,
TaskAnalysisResult,
TaskContent,
sse_json,
)
from app.service.task import (
Action,
ActionDecomposeProgressData,
Expand Down Expand Up @@ -501,17 +509,28 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
is_complex_task: bool
if len(attaches_to_use) > 0:
is_complex_task = True
task_lock._prefetched_summary = None
logger.info(
"[NEW-QUESTION] Has attachments"
", treating as complex task"
)
else:
is_complex_task = await question_confirm(
analysis = await analyze_task(
question_agent, question, task_lock
)
is_complex_task = analysis.is_complex
if (
is_complex_task
and analysis.task_name
and analysis.summary
):
task_lock._prefetched_summary = (
f"{analysis.task_name}|{analysis.summary}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems that different from the original one?
f"Task|{summary_task_content}" though I am not sure why we used the fixed Task before cc @Wendong-Fan

)
else:
task_lock._prefetched_summary = None
logger.info(
"[NEW-QUESTION] question_confirm"
" result: is_complex="
"[NEW-QUESTION] analyze_task result: is_complex="
f"{is_complex_task}"
)

Expand Down Expand Up @@ -736,57 +755,73 @@ async def run_decomposition():
except Exception:
pass

# Generate task summary
summary_task_agent = task_summary_agent(options)
try:
summary_task_content = await asyncio.wait_for(
summary_task(
summary_task_agent, camel_task
),
timeout=10,
)
task_lock.summary_generated = True
except TimeoutError:
logger.warning(
"summary_task timeout",
extra={
"project_id": options.project_id,
"task_id": options.task_id,
},
)
task_lock.summary_generated = True
content_preview = (
camel_task.content
if hasattr(camel_task, "content")
else ""
)
if content_preview is None:
content_preview = ""
if len(content_preview) > 80:
cp = content_preview[:80]
summary_task_content = cp + "..."
else:
summary_task_content = content_preview
summary_task_content = (
f"Task|{summary_task_content}"
)
except Exception:
# Generate task summary (use prefetched if available)
prefetched = getattr(
task_lock, "_prefetched_summary", None
)
if prefetched:
summary_task_content = prefetched
task_lock.summary_generated = True
content_preview = (
camel_task.content
if hasattr(camel_task, "content")
else ""
logger.debug(
"Using prefetched task summary from "
"analyze_task (issue #1427)"
)
if content_preview is None:
content_preview = ""
if len(content_preview) > 80:
cp = content_preview[:80]
summary_task_content = cp + "..."
else:
summary_task_content = content_preview
summary_task_content = (
f"Task|{summary_task_content}"
else:
summary_task_agent = task_summary_agent(
options
)
try:
summary_task_content = (
await asyncio.wait_for(
summary_task(
summary_task_agent,
camel_task,
),
timeout=10,
)
)
task_lock.summary_generated = True
except TimeoutError:
logger.warning(
"summary_task timeout",
extra={
"project_id": options.project_id,
"task_id": options.task_id,
},
)
task_lock.summary_generated = True
content_preview = (
camel_task.content
if hasattr(camel_task, "content")
else ""
)
if content_preview is None:
content_preview = ""
if len(content_preview) > 80:
cp = content_preview[:80]
summary_task_content = cp + "..."
else:
summary_task_content = content_preview
summary_task_content = (
f"Task|{summary_task_content}"
)
except Exception:
task_lock.summary_generated = True
content_preview = (
camel_task.content
if hasattr(camel_task, "content")
else ""
)
if content_preview is None:
content_preview = ""
if len(content_preview) > 80:
cp = content_preview[:80]
summary_task_content = cp + "..."
else:
summary_task_content = content_preview
summary_task_content = (
f"Task|{summary_task_content}"
)

state_holder["summary_task"] = summary_task_content
try:
Expand Down Expand Up @@ -1927,6 +1962,93 @@ def add_sub_tasks(
return added_tasks


async def analyze_task(
agent: ListenChatAgent,
question: str,
task_lock: TaskLock | None = None,
) -> TaskAnalysisResult:
"""Analyze user query in one LLM call: complexity + task name/summary.

Merges question_confirm and summary_task into a single request (issue #1427).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove

Falls back to question_confirm when structured output parsing fails.
"""
context_prompt = ""
if task_lock:
context_prompt = build_conversation_context(
task_lock, header="=== Previous Conversation ==="
)

full_prompt = f"""{context_prompt}User Query: {question}

Analyze this user query and return structured JSON with these fields:
- is_complex (boolean): True if this requires tools, code execution, file operations,
multi-step planning, or creating/modifying content. False if it can be answered
directly (greetings, fact queries, clarifications, status checks).
- task_name (string, only when is_complex): A short descriptive name for the task.
- summary (string, only when is_complex): A concise summary of the task's main points.

Examples of complex: "create a file", "search for X", "implement feature Y".
Examples of simple: "hello", "what is X?", "how are you?"

Return valid JSON only, no other text."""

try:
resp = agent.step(full_prompt, response_format=TaskAnalysisResult)

if not resp or not resp.msgs or len(resp.msgs) == 0:
logger.warning(
"analyze_task: no response, falling back to question_confirm"
)
is_complex = await question_confirm(agent, question, task_lock)
return TaskAnalysisResult(
is_complex=is_complex, task_name=None, summary=None
)

content = resp.msgs[0].content
parsed = getattr(resp.msgs[0], "parsed", None)

if parsed is not None and isinstance(parsed, TaskAnalysisResult):
logger.info(
"analyze_task: got structured result",
extra={"is_complex": parsed.is_complex},
)
return parsed

if content:
try:
data = json.loads(content.strip())
result = TaskAnalysisResult(
is_complex=bool(data.get("is_complex", True)),
task_name=data.get("task_name"),
summary=data.get("summary"),
)
logger.info(
"analyze_task: parsed JSON from content",
extra={"is_complex": result.is_complex},
)
return result
except (json.JSONDecodeError, TypeError):
pass

logger.warning(
"analyze_task: could not parse response, falling back to question_confirm"
)
is_complex = await question_confirm(agent, question, task_lock)
return TaskAnalysisResult(
is_complex=is_complex, task_name=None, summary=None
)

except Exception as e:
logger.warning(
f"analyze_task failed: {e}, falling back to question_confirm",
exc_info=True,
)
is_complex = await question_confirm(agent, question, task_lock)
return TaskAnalysisResult(
is_complex=is_complex, task_name=None, summary=None
)


async def question_confirm(
agent: ListenChatAgent, prompt: str, task_lock: TaskLock | None = None
) -> bool:
Expand Down
69 changes: 67 additions & 2 deletions backend/tests/app/service/test_chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
from camel.tasks import Task
from camel.tasks.task import TaskState

from app.model.chat import Chat, NewAgent
from app.model.chat import Chat, NewAgent, TaskAnalysisResult
from app.service.chat_service import (
add_sub_tasks,
analyze_task,
build_context_for_workforce,
collect_previous_task_context,
construct_workforce,
Expand Down Expand Up @@ -646,6 +647,65 @@ async def test_summary_task(self, mock_camel_agent):
)
mock_camel_agent.step.assert_called_once()

@pytest.mark.asyncio
async def test_analyze_task_structured_output(self, mock_camel_agent):
"""Test analyze_task with structured parsed output."""
mock_camel_agent.step.return_value.msgs[0].parsed = TaskAnalysisResult(
is_complex=True,
task_name="Web App Creation",
summary="Create a modern web application with auth",
)
mock_camel_agent.step.return_value.msgs[0].content = "{}"
mock_camel_agent.chat_history = []

result = await analyze_task(
mock_camel_agent, "Create a web app with authentication"
)

assert result.is_complex is True
assert result.task_name == "Web App Creation"
assert result.summary == "Create a modern web application with auth"
mock_camel_agent.step.assert_called_once()

@pytest.mark.asyncio
async def test_analyze_task_json_content(self, mock_camel_agent):
"""Test analyze_task parses JSON from content when parsed is None."""
mock_camel_agent.step.return_value.msgs[0].parsed = None
mock_camel_agent.step.return_value.msgs[
0
].content = '{"is_complex": false, "task_name": null, "summary": null}'
mock_camel_agent.chat_history = []

result = await analyze_task(mock_camel_agent, "hello")

assert result.is_complex is False
assert result.task_name is None
assert result.summary is None

@pytest.mark.asyncio
async def test_analyze_task_fallback_to_question_confirm(
self, mock_camel_agent
):
"""Test analyze_task falls back to question_confirm when parsing fails."""
first_response = MagicMock()
first_response.msgs = [MagicMock()]
first_response.msgs[0].parsed = None
first_response.msgs[0].content = "invalid json"

second_response = MagicMock()
second_response.msgs = [MagicMock()]
second_response.msgs[0].content = "yes"

mock_camel_agent.step.side_effect = [first_response, second_response]
mock_camel_agent.chat_history = []

result = await analyze_task(mock_camel_agent, "Create a file")

assert result.is_complex is True
assert result.task_name is None
assert result.summary is None
assert mock_camel_agent.step.call_count == 2

@pytest.mark.asyncio
async def test_new_agent_model_creation(self, sample_chat_data):
"""Test new_agent_model creates agent with proper configuration."""
Expand Down Expand Up @@ -893,7 +953,12 @@ async def test_step_solve_basic_workflow(
"app.service.chat_service.task_summary_agent"
) as mock_summary_agent,
patch(
"app.service.chat_service.question_confirm", return_value=True
"app.service.chat_service.analyze_task",
return_value=TaskAnalysisResult(
is_complex=True,
task_name="Test Task",
summary="Test summary",
),
),
patch(
"app.service.chat_service.summary_task",
Expand Down