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
4 changes: 2 additions & 2 deletions src/jrdev/agents/pipeline/fetch_context_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ async def ask_files_sufficient(self, files: List[str], user_task: str):
self.app.ui.update_task_info(sub_task_str, update={"sub_task_finished": True})

json_content = cutoff_string(response, "```json", "```")
tool_calls = json.loads(json_content)
try:
tool_calls = json.loads(json_content)
if tool_calls:
tool = tool_calls.get("tool")
if tool and tool == "read":
Expand All @@ -154,7 +154,7 @@ async def ask_files_sufficient(self, files: List[str], user_task: str):
if file not in files:
files.append(file)
self.app.logger.info(f"Adding file {file}")
except AttributeError as e:
except (json.JSONDecodeError, TypeError, AttributeError) as e:
self.app.logger.error("ask_files_sufficient: malformed additional files response %s", str(e))
self.app.ui.print_text("ask_files_sufficient response malformed", PrintType.ERROR)
return files
10 changes: 3 additions & 7 deletions src/jrdev/agents/research_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,7 @@ async def interpret(
summary_parts.append("No research actions were taken.")

summary = "\n".join(summary_parts)
self.thread.messages.append(
{"role": "assistant", "content": summary}
)
self.thread.add_response(summary, model=research_model)
return {"type": "summary", "data": summary}

if response_json is None:
Expand All @@ -111,9 +109,7 @@ async def interpret(
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)}
)
self.thread.add_response(json.dumps(response_json, indent=2), model=research_model)

decision = response_json.get("decision")

Expand Down Expand Up @@ -147,7 +143,7 @@ async def interpret(
if decision == "summary":
summary = response_json.get("response", "")
# Add summary to thread as final assistant message
self.thread.messages.append({"role": "assistant", "content": summary})
self.thread.add_response(summary, model=research_model)
return {"type": "summary", "data": summary}

self.logger.error(f"Research agent returned an unknown decision: {decision}. Aborting.")
Expand Down
10 changes: 5 additions & 5 deletions src/jrdev/agents/router_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ async def interpret(

# Use a specific, fast model for this routing task
router_model = self.app.profile_manager().get_model("intent_router")
response_model = router_model
response_text = await generate_llm_response(self.app, router_model, messages, task_id=worker_id)

# The user's input is part of the request, so add it to history.
Expand Down Expand Up @@ -97,6 +98,7 @@ async def interpret(
salvage_builder.add_user_message(response_text)

salvage_model = self.app.profile_manager().get_model("quick_reasoning")
response_model = salvage_model
response_text = await generate_llm_response(self.app, salvage_model, salvage_builder.build(), task_id=worker_id)

try:
Expand All @@ -105,15 +107,13 @@ async def interpret(
except (json.JSONDecodeError, KeyError) as e2:
self.logger.error(f"Failed to parse router JSON response{e2}\nResponse:\n {response_text}\n")
msg = "Sorry, I had issues parsing my response. Do you want to try again?"
self.thread.messages.append({"role": "assistant", "content": msg})
self.thread.add_response(msg, model=response_model)
self.app.ui.print_text(msg, print_type=PrintType.ERROR)
return None

# Add the structured assistant response to history *after* successful parsing.
# The content is the JSON string of the decision.
self.thread.messages.append(
{"role": "assistant", "content": json.dumps(response_json, indent=2)}
)
self.thread.add_response(json.dumps(response_json, indent=2), model=response_model)

decision = response_json.get("decision")

Expand Down Expand Up @@ -152,4 +152,4 @@ async def interpret(
self.app.ui.print_text(chat_response, print_type=PrintType.LLM)
return None

return None
return None
2 changes: 1 addition & 1 deletion src/jrdev/commands/compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ async def handle_compact(app: Any, args: List[str], worker_id: str) -> None:
# Create new messages in the required format
new_messages = [
{"role": "user", "content": compact_data["user"]},
{"role": "assistant", "content": compact_data["assistant"]},
{"role": "assistant", "content": compact_data["assistant"], "model": model},
]

# Replace the thread's messages with just these two
Expand Down
4 changes: 2 additions & 2 deletions src/jrdev/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async def handle_help(app: Any, args: List[str], _worker_id: str):
app.ui.print_text(
f" {cmd_format}{format_command_with_args('/model', '<list|set|remove|add|edit> [args]')}{reset} "
"- Manage and add models (/model add|edit <name> <provider> <is_think> <input_cost> <output_cost> "
"<context_window>)",
"<context_window>; /model <name> quantizations [int4, int8] for OpenRouter)",
print_type=None,
)
app.ui.print_text(f" {cmd_format}/models{reset} - List all available models", print_type=None)
Expand Down Expand Up @@ -215,7 +215,7 @@ async def handle_help_plain(app: Any, _args: List[str]):

app.ui.print_text(
" /model <list|set|remove|add|edit> [args] - Manage and add models (/model add|edit <name> <provider> "
"<is_think> <input_cost> <output_cost> <context_window>)",
"<is_think> <input_cost> <output_cost> <context_window>; /model <name> quantizations [int4, int8] for OpenRouter)",
print_type=None,
)
app.ui.print_text(" /models - List all available models", print_type=None)
Expand Down
79 changes: 79 additions & 0 deletions src/jrdev/commands/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
Model command implementation for the JrDev terminal.
Manages the user's list of available models and the active chat model.
"""
from __future__ import annotations

from typing import Any, List, Union

from jrdev.ui.ui import PrintType
from jrdev.utils.string_utils import is_valid_context_window, is_valid_cost, is_valid_name

OPENROUTER_PROVIDER = "open_router"


def _parse_bool(val: str) -> bool:
"""Parse a string to boolean, accepting common true/false values."""
Expand All @@ -21,6 +25,31 @@ def _parse_bool(val: str) -> bool:
raise ValueError(f"Invalid boolean value: {val}")


def _parse_quantizations(args: List[str]) -> List[str] | None:
"""Parse a quantization list from CLI args such as [int4, int8]."""
raw = " ".join(args).strip()
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1].strip()

if not raw:
return None

if "," in raw:
quantizations = [item.strip() for item in raw.split(",")]
else:
quantizations = raw.split()

quantizations = [item for item in quantizations if item]
if not quantizations:
return None

for quantization in quantizations:
if not is_valid_name(quantization, max_len=32):
return None

return quantizations


# pylint: disable=too-many-return-statements
def _parse_model_arguments(app, args: List[str], start_idx: int) -> Union[dict, None]:
"""Parse and validate model arguments starting from given index."""
Expand Down Expand Up @@ -101,6 +130,8 @@ async def handle_model(app, args: List[str], _worker_id: str):
list - Shows all models available in your configuration.
set <model_name> - Sets the active model for the chat.
remove <model_name> - Removes a model from your configuration.
<model_name> quantizations [values]
- Sets OpenRouter provider quantization filters.
add <name> <provider> <is_think> <input> <output> <context>
- Adds a new model to your configuration.
edit <name> <provider> <is_think> <input> <output> <context>
Expand All @@ -125,6 +156,8 @@ async def handle_model(app, args: List[str], _worker_id: str):
" /model list - Shows all models available in your user_models.json.\n"
" /model set <model_name> - Sets the active model for the chat.\n"
" /model remove <model_name> - Removes a model from your user_models.json.\n"
" /model <model_name> quantizations [int4, int8]\n"
" - Set OpenRouter provider quantization filters for a model.\n"
" /model add <name> <provider> <is_think> <input_cost> <output_cost> <context_window>\n"
" - Add a new model to your user_models.json.\n"
" <input_cost> and <output_cost> are the cost per 1,000,000 tokens (as a float, in dollars).\n"
Expand All @@ -145,6 +178,10 @@ async def handle_model(app, args: List[str], _worker_id: str):
return

subcommand = args[1].lower()
if len(args) >= 3 and args[2].lower() == "quantizations":
_handle_quantizations(app, args, available_model_names)
return

_handle_subcommand(app, subcommand, args, available_model_names)

if subcommand not in ["list", "set", "remove", "add", "edit"]:
Expand Down Expand Up @@ -174,6 +211,48 @@ def _handle_subcommand(app: Any, subcommand: str, args: List[str], available_mod
_handle_edit(app, args, available_model_names)


def _handle_quantizations(app: Any, args: List[str], available_model_names: List[str]) -> None:
if len(args) < 4:
app.ui.print_text("Usage: /model <model_name> quantizations [int4, int8]", PrintType.ERROR)
return

model_name = args[1]
if model_name not in available_model_names:
app.ui.print_text(f"Error: Model '{model_name}' not found in your configuration.", PrintType.ERROR)
return

model_info = next((model for model in app.get_models() if model["name"] == model_name), None)
if not model_info:
app.ui.print_text(f"Error: Model '{model_name}' not found in your configuration.", PrintType.ERROR)
return

provider = model_info.get("provider")
if provider != OPENROUTER_PROVIDER:
app.ui.print_text(
f"Error: Quantizations can only be set for OpenRouter models. '{model_name}' uses provider '{provider}'.",
PrintType.ERROR,
)
return

quantizations = _parse_quantizations(args[3:])
if quantizations is None:
app.ui.print_text(
"Invalid quantizations. Usage: /model <model_name> quantizations [int4, int8]", PrintType.ERROR
)
return

if not app.set_model_quantizations(model_name, quantizations):
app.logger.info(f"Failed to set quantizations for model {model_name}")
app.ui.print_text(f"Failed to set quantizations for model '{model_name}'", PrintType.ERROR)
return

app.logger.info(f"Set quantizations for model {model_name}: {quantizations}")
app.ui.print_text(
f"Set quantizations for model '{model_name}' to: {', '.join(quantizations)}",
PrintType.SUCCESS,
)


def _handle_set(app: Any, args: List[str], available_model_names: List[str]) -> None:
if len(args) < 3:
app.ui.print_text("Usage: /model set <model_name>", PrintType.ERROR)
Expand Down
14 changes: 13 additions & 1 deletion src/jrdev/core/application.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import asyncio
import json
import os
Expand Down Expand Up @@ -457,6 +459,15 @@ def edit_model(self, model_name: str, provider: str, is_think: bool, input_cost:
self.ui.model_list_updated()
return True

def set_model_quantizations(self, model_name: str, quantizations: List[str]) -> bool:
"""Set OpenRouter provider quantization filters for a model and flush to disk."""
if not self.state.model_list.update_model_quantizations(model_name, quantizations):
return False

save_models(self.state.model_list.get_model_list())
self.ui.model_list_updated()
return True

def refresh_model_list(self):
# 1) grab every model from user's config (single source of truth)
models = load_models()
Expand Down Expand Up @@ -575,9 +586,10 @@ async def process_chat_input(self, user_input, worker_id=None):

# 3) stream the LLM response
content = f"{USER_INPUT_PREFIX}{user_input}"
response_model = self.state.model
async for chunk in self.message_service.stream_message(msg_thread, content, worker_id):
# for each piece of text we hand it off to the UI
self.ui.stream_chunk(thread_id, chunk)
self.ui.stream_chunk(thread_id, chunk, response_model)
# 4) at the end, notify UI to refresh thread list or button state
self.ui.chat_thread_update(thread_id)

Expand Down
6 changes: 3 additions & 3 deletions src/jrdev/messages/message_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class MessageBuilder:
def __init__(self, app: Any):
self.app = app
self.messages: List[Dict[str, str]] = []
self.messages: List[Dict[str, Any]] = []
self.files: Set[str] = set()
self.project_files: Set[str] = set()
self.include_tree: bool = False
Expand All @@ -33,7 +33,7 @@ def add_assistant_message(self, content: str) -> None:
"""Add an assistant message to the conversation"""
self.messages.append({"role": "assistant", "content": content})

def add_historical_messages(self, messages: List[Dict[str, str]]) -> None:
def add_historical_messages(self, messages: List[Dict[str, Any]]) -> None:
"""Add historical message chain"""
self.messages.extend(messages)

Expand Down Expand Up @@ -176,7 +176,7 @@ def finalize_user_section(self) -> None:
def clean(self) -> None:
self.messages = [m for m in self.messages if m["content"] != ""]

def build(self) -> List[Dict[str, str]]:
def build(self) -> List[Dict[str, Any]]:
"""Return the fully constructed message list"""
if not self.isUserSectionFinal:
self.finalize_user_section()
Expand Down
29 changes: 21 additions & 8 deletions src/jrdev/messages/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, thread_id: str):
"""
self.thread_id: str = thread_id
self.name: Optional[str] = None
self.messages: List[Dict[str, str]] = []
self.messages: List[Dict[str, Any]] = []
self.context: Set[str] = set()
self.embedded_files: Set[str] = set()
self.token_usage: Dict[str, int] = {"input": 0, "output": 0}
Expand Down Expand Up @@ -171,31 +171,44 @@ def add_embedded_files(self, files: List[str]) -> None:
self.metadata["last_modified"] = datetime.now()

@auto_persist
def add_response(self, response: str) -> None:
def add_response(self, response: str, model: Optional[str] = None) -> None:
"""Add a complete assistant response to the thread history."""
self.messages.append({"role": "assistant", "content": response})
message = {"role": "assistant", "content": response}
if model:
message["model"] = model
self.messages.append(message)
self.metadata["last_modified"] = datetime.now()

@auto_persist
def add_response_partial(self, chunk: str) -> None:
def add_response_partial(self, chunk: str, model: Optional[str] = None) -> None:
"""Add a partial assistant response chunk to the thread history."""
if self.messages and self.messages[-1].get("role") == "assistant":
self.messages[-1]["content"] += chunk
if model:
self.messages[-1]["model"] = model
else:
self.messages.append({"role": "assistant", "content": chunk})
message = {"role": "assistant", "content": chunk}
if model:
message["model"] = model
self.messages.append(message)
self.metadata["last_modified"] = datetime.now()

@auto_persist
def finalize_response(self, full_text: str) -> None:
def finalize_response(self, full_text: str, model: Optional[str] = None) -> None:
"""Finalize the assistant response, replacing partials with full text."""
if self.messages and self.messages[-1].get("role") == "assistant":
self.messages[-1]["content"] = full_text
if model:
self.messages[-1]["model"] = model
else:
self.messages.append({"role": "assistant", "content": full_text})
message = {"role": "assistant", "content": full_text}
if model:
message["model"] = model
self.messages.append(message)
self.metadata["last_modified"] = datetime.now()

@auto_persist
def set_compacted(self, messages: List[Dict[str, str]]) -> None:
def set_compacted(self, messages: List[Dict[str, Any]]) -> None:
"""Replace the existing messages list and reset file states."""
self.messages = messages
self.context = set()
Expand Down
14 changes: 13 additions & 1 deletion src/jrdev/models/model_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ def update_model(self, model_name: str, provider: str, is_think: bool, input_cos
return True
return False

def update_model_quantizations(self, model_name: str, quantizations: List[str]) -> bool:
"""
Update OpenRouter provider quantization filters for an existing model.
Returns True if the model was updated, False if the model was not found.
"""
with self._lock:
for m in self._model_list:
if m["name"] == model_name:
m["quantizations"] = quantizations
return True
return False

def add_model(self, model_name: str, provider: str, is_think: bool, input_cost: int, output_cost: int, context_window: int) -> bool:
"""
Add a new model to the model list if it does not already exist.
Expand All @@ -100,4 +112,4 @@ def add_model(self, model_name: str, provider: str, is_think: bool, input_cost:
"context_tokens": context_window
}
self._model_list.append(model_dict)
return True
return True
Loading
Loading