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
112 changes: 112 additions & 0 deletions src/jrdev/agents/research_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json
from typing import Any, Dict, List, Optional

from jrdev.core.tool_call import ToolCall
from jrdev.file_operations.file_utils import cutoff_string
from jrdev.messages.thread import MessageThread
from jrdev.messages.message_builder import MessageBuilder
from jrdev.prompts.prompt_utils import PromptManager
from jrdev.services.llm_requests import generate_llm_response
from jrdev.ui.ui import PrintType


class ResearchAgent:
ALLOWED_TOOLS = {"web_search", "web_scrape_url"}

def __init__(self, app: Any, thread: MessageThread):
self.app = app
self.logger = app.logger
self.thread = thread

async def interpret(
self, user_input: str, worker_id: str, previous_tool_calls: List[ToolCall] = None
) -> Optional[Dict[str, Any]]:
"""
Interpret user input for research, decide on a tool to use, or provide a summary.
Returns a dictionary representing the LLM's decision.
"""
builder = MessageBuilder(self.app)
# Use the agent's private message history
if self.thread.messages:
builder.add_historical_messages(self.thread.messages)

# Build the prompt for the LLM
research_prompt = PromptManager().load("researcher/research_prompt")
builder.add_system_message(research_prompt)

# Add the actual user request
builder.append_to_user_section(f"User Research Request: {user_input}")
if previous_tool_calls:
call_summaries = "\n--- Previous Research Actions For This Request ---\n"
for tc in previous_tool_calls:
call_summaries += f"Tool Used: {tc.formatted_cmd}\nTool Results: {tc.result}\n"
builder.append_to_user_section(call_summaries)

builder.finalize_user_section()

messages = builder.build()

# The user's input is part of the request, so add it to history.
if not previous_tool_calls:
self.thread.messages.append({"role": "user", "content": f"**Researching**: {user_input}"})

# Use a specific model for this task
research_model = self.app.state.model
response_text = await generate_llm_response(self.app, research_model, messages, task_id=worker_id)

json_content = ""
try:
json_content = cutoff_string(response_text, "```json", "```")
response_json = json.loads(json_content)
except (json.JSONDecodeError, KeyError) as e:
self.logger.error(
f"Failed to parse research agent LLM response: {e}\nResponse:\n {response_text}\nRaw:\n{json_content}")
self.app.ui.print_text(
"Research agent had an issue parsing its own response. This may be a temporary issue. Aborting research task.",
print_type=PrintType.ERROR,
)
return None

# Add the structured assistant response to history *after* successful parsing.
self.thread.messages.append(
{"role": "assistant", "content": json.dumps(response_json, indent=2)}
)

decision = response_json.get("decision")

if decision == "execute_action":
action = response_json.get("action")
if not action:
self.logger.error(f"Research agent decision was 'execute_action' but no action was provided. Response: {response_json}")
self.app.ui.print_text("Research agent decided to execute an action, but encountered an error. Aborting.", print_type=PrintType.ERROR)
return None

tool_name = action.get("name")
if tool_name not in self.ALLOWED_TOOLS:
self.logger.error(
f"Research agent attempted to use an unauthorized tool: {tool_name}. Allowed tools are: {self.ALLOWED_TOOLS}"
)
self.app.ui.print_text(
f"Research agent tried to use an unauthorized tool '{tool_name}'. Aborting research task.",
print_type=PrintType.ERROR,
)
return None

tool_call = ToolCall(
action_type="tool",
command=tool_name,
args=action["args"],
reasoning=response_json.get("reasoning", ""),
has_next=True, # Research agent always has a next step until it summarizes
)
return {"type": "tool_call", "data": tool_call}

if decision == "summary":
summary = response_json.get("response", "")
# Add summary to thread as final assistant message
self.thread.messages.append({"role": "assistant", "content": summary})
return {"type": "summary", "data": summary}

self.logger.error(f"Research agent returned an unknown decision: {decision}. Aborting.")
self.app.ui.print_text(f"Research agent returned an unknown decision: {decision}. Aborting.", print_type=PrintType.ERROR)
return None
2 changes: 2 additions & 0 deletions src/jrdev/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from jrdev.commands.models import handle_models
from jrdev.commands.projectcontext import handle_projectcontext
from jrdev.commands.provider import handle_provider
from jrdev.commands.handle_research import handle_research
from jrdev.commands.routeragent import handle_routeragent
from jrdev.commands.stateinfo import handle_stateinfo
from jrdev.commands.tasks import handle_tasks
Expand All @@ -49,6 +50,7 @@
"handle_modelprofile",
"handle_projectcontext",
"handle_provider",
"handle_research",
"handle_routeragent",
"handle_stateinfo",
"handle_tasks",
Expand Down
152 changes: 152 additions & 0 deletions src/jrdev/commands/handle_research.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import asyncio
import httpx
from typing import Any, List, Optional

from jrdev.agents import agent_tools
from jrdev.agents.research_agent import ResearchAgent
from jrdev.core.tool_call import ToolCall
from jrdev.ui.ui import PrintType


async def handle_research(app: Any, args: List[str], worker_id: str, chat_thread_id: Optional[str] = None) -> None:
"""
Initiates a research agent to investigate a topic using web search and scraping tools.
Usage: /research <your query>
If chat_thread_id is provided, it runs in the context of that chat.
"""
if len(args) < 2:
if not chat_thread_id: # Only show usage error for command line
app.ui.print_text("Usage: /research <your query>", print_type=PrintType.ERROR)
return

user_input = " ".join(args[1:])

# Determine the research thread and initial output
if chat_thread_id:
research_thread = app.get_thread(chat_thread_id)
else:
app.ui.print_text(f'Starting research for: "{user_input}"\n', print_type=PrintType.INFO)
new_thread_id = app.create_thread(meta_data={"type": "research", "topic": user_input})
research_thread = app.get_thread(new_thread_id)

# Initialize the research agent
research_agent = ResearchAgent(app, research_thread)

calls_made: List[ToolCall] = []
max_iter = app.user_settings.max_router_iterations
i = 0
summary = None

while i < max_iter:
i += 1
if not chat_thread_id:
app.ui.print_text(f"--- Research Iteration {i}/{max_iter} ---", print_type=PrintType.INFO)
await asyncio.sleep(0.01)

# Create a sub-task ID for this iteration for tracking purposes
sub_task_id = worker_id
if worker_id:
sub_task_id = f"{worker_id}:{i}"
app.ui.update_task_info(
worker_id, update={"new_sub_task": sub_task_id, "description": "Research: Thinking..."}
)

decision = await research_agent.interpret(user_input, sub_task_id, calls_made)

if worker_id:
app.ui.update_task_info(sub_task_id, update={"sub_task_finished": True})

if not decision:
msg = "Research task concluded due to an agent error."
if chat_thread_id:
app.ui.stream_chunk(chat_thread_id, msg)
app.ui.chat_thread_update(chat_thread_id)
else:
app.ui.print_text(msg, print_type=PrintType.ERROR)
break

decision_type = decision.get("type")
data = decision.get("data")

if decision_type == "summary":
summary = data
break

if decision_type == "tool_call":
tool_call: ToolCall = data
command_to_execute = tool_call.formatted_cmd

# Check for duplicate calls to avoid redundant work and cost
cached_call = next((call for call in calls_made if call.formatted_cmd == command_to_execute), None)

if cached_call:
tool_call.result = cached_call.result
if not chat_thread_id:
app.ui.print_text(f"Skipping duplicate tool call (using cached result): {command_to_execute}", print_type=PrintType.WARNING)
else:
if chat_thread_id:
feedback_msg = f"Running: `{command_to_execute}`\n> {tool_call.reasoning}"
app.ui.stream_chunk(chat_thread_id, feedback_msg)
app.ui.chat_thread_update(chat_thread_id)
else: # Only print progress to terminal
app.ui.print_text(f"Running tool: {command_to_execute}\nPurpose: {tool_call.reasoning}\n",
print_type=PrintType.PROCESSING)

try:
if tool_call.command == "web_search":
tool_call.result = agent_tools.web_search(tool_call.args)
elif tool_call.command == "web_scrape_url":
tool_call.result = await agent_tools.web_scrape_url(tool_call.args)
else:
error_msg = f"Error: Research Agent tried to use an unauthorized tool: '{tool_call.command}'"
if not chat_thread_id:
app.ui.print_text(error_msg, PrintType.ERROR)
tool_call.result = error_msg
except httpx.HTTPStatusError as e:
error_message = f"HTTP error during '{tool_call.command}': {e.response.status_code} {e.response.reason_phrase} for URL {e.request.url}"
app.logger.error(f"Tool execution failed: {error_message}", exc_info=True)
tool_call.result = error_message
except httpx.RequestError as e:
error_message = f"Network error during '{tool_call.command}': {str(e)}. This could be a timeout, DNS issue, or invalid URL."
app.logger.error(f"Tool execution failed: {error_message}", exc_info=True)
tool_call.result = error_message
except asyncio.TimeoutError:
error_message = f"Timeout during '{tool_call.command}'. The operation took too long to complete."
app.logger.error(f"Tool execution failed: {error_message}", exc_info=True)
tool_call.result = error_message
except (ValueError, IndexError) as e:
error_message = f"Invalid arguments for tool '{tool_call.command}': {str(e)}"
app.logger.error(f"Tool execution failed: {error_message}", exc_info=True)
tool_call.result = error_message
except Exception as e:
error_message = f"An unexpected error occurred while executing tool '{tool_call.command}': {str(e)}"
app.logger.error(f"Tool execution failed: {error_message}", exc_info=True)
tool_call.result = error_message

calls_made.append(tool_call)
else:
msg = f"Unknown decision type from research agent: {decision_type}"
if chat_thread_id:
app.ui.stream_chunk(chat_thread_id, msg)
app.ui.chat_thread_update(chat_thread_id)
else:
app.ui.print_text(msg, print_type=PrintType.ERROR)
break

if summary:
if chat_thread_id:
# The agent adds the summary to the thread history. We stream it to the UI.
app.ui.stream_chunk(chat_thread_id, summary)
app.ui.chat_thread_update(chat_thread_id)
else:
# For command-line, print the summary to the terminal.
app.ui.print_text("\n--- Research Summary ---\n", print_type=PrintType.SUCCESS)
app.ui.print_text(summary, print_type=PrintType.INFO)

if i >= max_iter and not summary:
msg = "Research agent hit maximum iterations for this request. You can adjust this limit using the /routeragent command."
if chat_thread_id:
app.ui.stream_chunk(chat_thread_id, msg)
app.ui.chat_thread_update(chat_thread_id)
else:
app.ui.print_text(msg, print_type=PrintType.WARNING)
48 changes: 48 additions & 0 deletions src/jrdev/commands/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
- /thread view [COUNT]: View conversation history in the current thread (default: 10)
- /thread delete THREAD_ID: Delete an existing thread

Additional toggle:
- /thread websearch on|off: Enable or disable per-thread web search mode used by chat input

For more details, see the docs/threads.md documentation.
"""

Expand Down Expand Up @@ -130,6 +133,20 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
)
delete_parser.add_argument("thread_id", type=str, help="Unique ID of the thread to delete")

# Web search toggle (per-thread)
websearch_parser = subparsers.add_parser(
"websearch",
help="Toggle web search for current thread",
description="Enable or disable per-thread web search mode",
epilog=f"Example: {format_command_with_args_plain('/thread websearch', 'on')}",
)
websearch_parser.add_argument(
"state",
type=str,
choices=["on", "off"],
help="Turn web search on or off for the current thread",
)

try:
if any(arg in ["-h", "--help"] for arg in args[1:]):
if len(args) == 2 and args[1] in ["-h", "--help"]:
Expand Down Expand Up @@ -167,6 +184,8 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
await _handle_view_conversation(app, parsed_args)
elif parsed_args.subcommand == "delete":
await _handle_delete_thread(app, parsed_args)
elif parsed_args.subcommand == "websearch":
await _handle_websearch_toggle(app, parsed_args)
else:
app.ui.print_text("Error: Missing subcommand", PrintType.ERROR)
app.ui.print_text("Available Thread Subcommands:", PrintType.HEADER)
Expand Down Expand Up @@ -228,6 +247,11 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
format_command_with_args_plain("/thread delete", "<thread_id>"),
"Remove an unwanted thread\nExample: /thread delete thread_abc",
),
(
"Web Search",
format_command_with_args_plain("/thread websearch", "on|off"),
"Enable or disable per-thread web search mode used by chat input\nExample: /thread websearch on",
),
]

for header, cmd, desc in sections:
Expand Down Expand Up @@ -419,3 +443,27 @@ async def _handle_delete_thread(app: Any, args: argparse.Namespace) -> None:
thread_id = f"thread_{thread_id}"
app.ui.print_text(f"Deleted thread: {thread_id}", PrintType.SUCCESS)
app.ui.chat_thread_update(app.state.active_thread)


async def _handle_websearch_toggle(app: Any, args: argparse.Namespace) -> None:
"""Enable or disable per-thread web search mode.

When enabled, chat input will route the user's message through the research agent
rather than the default chat model for the active thread.
"""
thread = app.state.get_current_thread()
if not thread:
app.ui.print_text("No active thread.", PrintType.ERROR)
return

enable = args.state.lower() == "on"
thread.metadata["web_search_enabled"] = enable
try:
thread.save() # Persist toggle if possible
except Exception:
# Non-fatal if persistence fails; runtime state still updated
pass

state_str = "enabled" if enable else "disabled"
app.ui.print_text(f"Web search {state_str} for thread {thread.thread_id}", PrintType.SUCCESS)
app.ui.chat_thread_update(thread.thread_id)
Loading