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
25 changes: 25 additions & 0 deletions qgo/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,31 @@ def run(self, user_message: str) -> str:

def _send(self, messages: list[dict]) -> str:
"""Send messages to the LLM and return the complete response."""
# Warn if the estimated token count approaches the context window
try:
def _content_text(content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
p.get("text", "") for p in content if isinstance(p, dict)
)
return ""

estimated = sum(
self.llm.count_tokens(_content_text(m.get("content")))
for m in messages
)
limit = self.llm.context_window
if estimated > limit * 0.9:
self._warn(
f"⚠️ Context is ~{estimated:,} tokens, close to the "
f"{limit:,}-token limit for {self.llm.model_name}. "
"Consider using /drop to remove files or /clear to reset history."
)
except Exception:
pass # Token estimation is best-effort; never block the request

stream = self.config.stream

if stream and self.io:
Expand Down
4 changes: 4 additions & 0 deletions qgo/llm/litellm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ def _call_with_retry(self, params: dict):
wait = 2 ** attempt
time.sleep(wait)
last_error = e
except litellm.exceptions.Timeout as e:
wait = 2 ** attempt
time.sleep(wait)
last_error = e
except Exception as e:
raise e

Expand Down
64 changes: 61 additions & 3 deletions qgo/ui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import glob as glob_module
import shlex
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -62,6 +63,7 @@ def handle(self, text: str) -> bool:
"/help": self._cmd_help,
"/exit": self._cmd_exit,
"/quit": self._cmd_exit,
"/agent": self._cmd_agent,
}.get(cmd)

if handler:
Expand Down Expand Up @@ -175,8 +177,9 @@ def _cmd_run(self, args: str) -> None:
self.io.print_warning("Usage: /run <shell command>")
return
try:
cmd = shlex.split(args)
result = subprocess.run(
args, shell=True, capture_output=True, text=True, timeout=60
cmd, shell=False, capture_output=True, text=True, timeout=60
)
output = (result.stdout + result.stderr).strip()
if output:
Expand Down Expand Up @@ -280,10 +283,10 @@ def _cmd_git(self, args: str) -> None:
if not args:
self.io.print_warning("Usage: /git <git subcommand>")
return
cmd = f"git {args}"
try:
cmd = ["git"] + shlex.split(args)
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=30
cmd, shell=False, capture_output=True, text=True, timeout=30
)
output = (result.stdout + result.stderr).strip()
if output:
Expand Down Expand Up @@ -325,6 +328,61 @@ def _cmd_ls(self, args: str) -> None:
except PermissionError:
self.io.print_error(f"Permission denied: {path}")

def _cmd_agent(self, args: str) -> None:
"""Run the multi-agent pipeline (or a single named agent).

Usage:
/agent <task description>
/agent --agent coder <task description>
"""
if not args:
self.io.print_warning(
"Usage: /agent [--agent <name>] <task description>\n"
"Available agents: planner, coder, reviewer, tester, "
"debugger, doc_writer, security, refactor"
)
Comment on lines +339 to +343
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

_cmd_agent advertises planner as an available --agent option, but AgentOrchestrator.run_single() only supports names in orchestrator._agents (coder/reviewer/tester/debugger/doc_writer/security/refactor) and will raise ValueError for planner. Either remove planner from the CLI/help list, or update run_single() / orchestrator internals to allow running the planner directly.

Copilot uses AI. Check for mistakes.
return

# Parse optional --agent flag
agent_name: str | None = None
task = args
parts = args.split(maxsplit=2)
if len(parts) >= 2 and parts[0] == "--agent":
agent_name = parts[1]
task = parts[2] if len(parts) > 2 else ""
if not task:
self.io.print_warning("Please provide a task description after --agent <name>.")
return
Comment on lines +346 to +355
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

--agent values are passed through as-is. Since AgentOrchestrator keys are lowercase (e.g. "coder"), /agent --agent Coder ... will currently fail even though it’s an obvious user input variant. Consider normalizing agent_name = agent_name.lower() (and maybe validating against orchestrator.list_agents() for a better error message) before calling run_single().

Copilot uses AI. Check for mistakes.

try:
from qgo.agents.orchestrator import AgentOrchestrator

files = {
str(fc.path): fc.content
for fc in self.coder.chat_files
}
orchestrator = AgentOrchestrator(
llm=self.coder.llm,
config=self.coder.config,
io=self.io,
)
if agent_name:
result = orchestrator.run_single(agent_name, task, context={"files": files})
report = result.output if result.success else f"Agent error: {result.error}"
else:
report = orchestrator.run(task, files=files)

self.io.print_assistant(report)
# Add the report to conversation context
self.coder.messages.append({
"role": "assistant",
"content": report,
})
Comment on lines +375 to +380
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

BaseCoder.messages is a list[Message] and _build_messages() calls msg.to_dict() for each prior message. Appending a raw dict here will cause an AttributeError the next time the coder sends a message. Append a qgo.models.Message(role=..., content=...) instead (and consider aligning the other command handlers that also append dicts).

Copilot uses AI. Check for mistakes.
except ValueError as exc:
self.io.print_error(str(exc))
except Exception as exc:
self.io.print_error(f"Multi-agent pipeline error: {exc}")

def _cmd_config(self, args: str) -> None:
self.io.print_info(str(self.coder.config))

Expand Down
2 changes: 2 additions & 0 deletions qgo/ui/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ def print_help(self) -> None:
| `/paste` | Paste clipboard content |
| `/ls [path]` | List directory files |
| `/config` | Show current config |
| `/agent [--agent <name>] <task>` | Run multi-agent pipeline (or a specific agent) |
| `/help` | Show this help |
| `/exit` or `/quit` | Exit QGo |

Expand All @@ -305,5 +306,6 @@ def print_help(self) -> None:
- Press **Ctrl+D** to exit
- Use `/add *.py` to add multiple files with glob patterns
- Use `/image` before your question to send images to vision-capable models
- Use `/agent` to dispatch complex tasks to the full multi-agent pipeline
"""
self.console.print(Markdown(help_md))
33 changes: 32 additions & 1 deletion qgo/utils/web_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ def fetch_url(url: str, timeout: int = 15) -> str:
Cleaned text content suitable for LLM context.
Empty string if fetching fails.
"""
try:
_validate_url(url)
except ValueError as exc:
return f"[{exc}]"
Comment on lines +29 to +30
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

fetch_url() docstring says it returns an empty string on failure, but on URL validation failure it now returns a non-empty bracketed error string. Because callers (e.g. /web) treat any non-empty return as success, invalid URLs can be incorrectly added to conversation context as “content”. Consider returning "" (to match the docstring) or changing the function contract + updating callers to handle an explicit error value.

Suggested change
except ValueError as exc:
return f"[{exc}]"
except ValueError:
return ""

Copilot uses AI. Check for mistakes.

try:
import requests
from bs4 import BeautifulSoup
Expand Down Expand Up @@ -95,6 +100,21 @@ def fetch_url(url: str, timeout: int = 15) -> str:
return f"[Error fetching {url}: {exc}]"


def _validate_url(url: str) -> None:
"""Raise ValueError if *url* uses a disallowed scheme.

Only http and https are permitted to prevent SSRF via file://, ftp://,
gopher://, etc.
"""
import urllib.parse

parsed = urllib.parse.urlparse(url)
if not parsed.scheme or parsed.scheme not in ("http", "https"):
raise ValueError(
f"Disallowed URL scheme {parsed.scheme!r}. Only http/https are permitted."
)
Comment on lines +103 to +115
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

_validate_url() only restricts the URL scheme. This prevents file:// etc., but it does not prevent SSRF to internal services over http(s) (e.g. http://localhost, http://169.254.169.254). If the goal is SSRF mitigation rather than just scheme hardening, consider additionally rejecting loopback/private/link-local IPs and optionally enforcing an allowlist of hosts.

Copilot uses AI. Check for mistakes.


def fetch_page_info(url: str, timeout: int = 15) -> dict:
"""Fetch a web page and return structured info for browser-view display.

Expand All @@ -109,6 +129,12 @@ def fetch_page_info(url: str, timeout: int = 15) -> dict:
"links": [],
"content": "",
}
try:
_validate_url(url)
except ValueError as exc:
result["content"] = f"[{exc}]"
return result

try:
import requests
from bs4 import BeautifulSoup
Expand Down Expand Up @@ -159,7 +185,7 @@ def fetch_page_info(url: str, timeout: int = 15) -> dict:
return result



def _fetch_plain(url: str, timeout: int = 15) -> str:
"""Minimal fallback using only urllib (no requests/bs4)."""
try:
import urllib.request
Expand All @@ -179,6 +205,11 @@ def fetch_image_base64(url: str, timeout: int = 15) -> str | None:

For use with vision-capable models.
"""
try:
_validate_url(url)
except ValueError:
return None

try:
import base64

Expand Down
Loading