fix multi-query agent workspace searches

This commit is contained in:
Hamza Ayed
2026-10-04 17:42:57 +03:00
parent b3dc6154e5
commit a62a831e90
6 changed files with 253 additions and 17 deletions
+67 -7
View File
@@ -1766,6 +1766,30 @@ async def run_agent(
return await _execute_agent(request, user_id=user_id)
def _explicit_workspace_search_queries(task: str) -> list[str]:
"""Extract a small numbered list of explicit workspace search targets."""
marker = re.search(r"(?:للعثور\s+على|to\s+find)\s*[::]", task, re.IGNORECASE)
if marker is None:
return []
remainder = re.split(
r"\s+(?:بعد\s+كل\s+بحث|ثم\s+لخّص|ثم\s+لخص|after\s+each\s+search|then\s+summari[sz]e)\b",
task[marker.end() :],
maxsplit=1,
flags=re.IGNORECASE,
)[0]
item_pattern = re.compile(
r"(?<![\w.])(?:\(\s*[1-5]\s*\)|[1-5][.)])\s*(.*?)"
r"(?=(?:\s*[,،;]\s*)?(?:\(\s*[1-5]\s*\)|[1-5][.)])\s*|$)",
re.DOTALL,
)
queries = [
item.strip(" \t\r\n.,،;:")
for item in item_pattern.findall(remainder)
if item.strip(" \t\r\n.,،;:")
]
return queries[:MAX_AGENT_TOOL_CALLS] if len(queries) > 1 else []
async def _execute_agent(
request: AgentRequest,
report_progress: Any | None = None,
@@ -1812,6 +1836,9 @@ async def _execute_agent(
for phrase in (
"ابحث في ملفات المشروع",
"ابحث في ملفات مساحة العمل",
"البحث في مساحة العمل",
"البحث في ملفات مساحة العمل",
"أداة البحث في مساحة العمل",
"ابحث في الملفات",
"ابحث عن الملف",
"ابحث داخل المشروع",
@@ -1857,17 +1884,39 @@ async def _execute_agent(
prefetched_knowledge = _knowledge_context_for_model(prefetched_results)
prefetched_workspace: list[dict[str, str]] = []
explicit_workspace_queries = _explicit_workspace_search_queries(request.task)
workspace_search_executed = (
explicit_workspace_search
and selected_workspace is not None
and (selected_skill is None or "search_workspace" in selected_skill.allowed_tools)
)
if workspace_search_executed:
await report("ينفذ البحث الصريح في مساحة العمل المسموحة.")
matches = workspace.retrieve(request.task, selected_workspace)
prefetched_workspace = [
{"path": path, "excerpt": excerpt} for path, excerpt in matches
]
queries = explicit_workspace_queries or [request.task]
matches_by_query: list[list[tuple[str, str]]] = []
for query in queries:
await report(
f"يبحث قراءةً فقط في مساحة العمل عن: {query[:100]}"
if explicit_workspace_queries
else "ينفذ البحث الصريح في مساحة العمل المسموحة."
)
matches_by_query.append(
workspace.retrieve(query, selected_workspace, limit=3)
)
seen_matches: set[tuple[str, str]] = set()
context_chars = 0
for result_index in range(3):
for matches in matches_by_query:
if result_index >= len(matches):
continue
path, excerpt = matches[result_index]
identity = (path, excerpt)
if identity in seen_matches:
continue
if len(prefetched_workspace) >= 6 or context_chars + len(excerpt) > 9_000:
continue
seen_matches.add(identity)
prefetched_workspace.append({"path": path, "excerpt": excerpt})
context_chars += len(excerpt)
if selected_skill is None or "calculator" in selected_skill.allowed_tools:
resolved_calculation = requested_workspace_constant_calculation(
request.task, prefetched_workspace
@@ -1931,7 +1980,7 @@ async def _execute_agent(
}
)
if selected_workspace is not None:
if not workspace_search_executed and (
if (not workspace_search_executed or explicit_workspace_queries) and (
selected_skill is None or "search_workspace" in selected_skill.allowed_tools
):
tools.append(
@@ -2013,7 +2062,7 @@ async def _execute_agent(
"content": (
"أنت وكيل محلي يستخدم حتى ثلاث خطوات أدوات مسموحة بالتتابع، أداة واحدة في كل خطوة. استخدم calculator للحسابات عندما تحتاجها. "
+ (
"بحث الخادم في مساحة العمل المسموحة مسبقًا عن طلب المستخدم؛ استخدم المقتطفات المعروضة ولا تكرر البحث. "
"بحث الخادم في مساحة العمل المسموحة مسبقًا عن الطلب؛ استخدم المقتطفات ولا تكرر أهداف البحث المكتملة. "
if workspace_search_executed
else "استخدم search_workspace عند الحاجة للعثور على مقاطع من مساحة العمل المسموحة. "
)
@@ -2084,6 +2133,17 @@ async def _execute_agent(
# Reduce stochastic generic answers when the user supplied concrete files
# and expects the agent to ground its response in their contents.
payload["temperature"] = 0.0
if (
("heartbeat" in task_lower or "نبضة" in task_lower)
and ("timeout" in task_lower or "مهلة" in task_lower)
):
payload["messages"][0]["content"] += (
" اشرح كل قيمة من مقتطف تنفيذ مطابق، ولا تستنتج مهلة إجمالية للوكيل من مهلة استدعاء واحد. "
"ميّز بين: heartbeat وهي فترة دورية لإرسال إشارة أثناء خمول SSE وليست مهلة اتصال؛ "
"و`Stream.timeout` وهي مهلة خمول بين بيانات HTTP؛ و`get_completion(timeout_seconds=...)` "
"وهي مهلة لاستدعاء نموذج واحد، لا للحلقة كاملة. إذا لم تجد حدًا إجماليًا صريحًا فقل ذلك. "
"لا تعتبر هذه التعليمات دليلًا؛ الدليل هو الأسطر المسترجعة من ملفات التنفيذ."
)
if (
selected_file_context
and selected_skill is not None
+82 -4
View File
@@ -476,6 +476,52 @@ def list_knowledge_files(root: Path) -> list[Path]:
return files
def _matching_excerpt(text: str, terms: set[str], max_chars: int) -> str:
lines = text.splitlines()
if not lines:
return text[:max_chars]
scored_lines: list[tuple[int, int]] = []
for index, line in enumerate(lines):
words = set(re.findall(r"[\w\u0600-\u06ff]+", line.casefold()))
matched = terms & words
if matched:
score = sum(2 if len(term) >= 8 else 1 for term in matched)
scored_lines.append((score, index))
if not scored_lines:
return text[:max_chars]
windows: list[tuple[int, int, int]] = []
for score, index in scored_lines:
start = max(0, index - 1)
end = min(len(lines), index + 2)
windows.append((score, start, end))
windows.sort(key=lambda item: (-item[0], item[1]))
selected: set[int] = set()
for _score, start, end in windows:
if any(line_number in selected for line_number in range(start, end)):
continue
candidate = set(range(start, end))
rendered = [f"{line_number + 1}: {lines[line_number]}" for line_number in sorted(selected | candidate)]
if len("\n".join(rendered)) > max_chars:
continue
selected.update(candidate)
if not selected:
_score, index = scored_lines[0]
line = lines[index]
matching_terms = [term for term in terms if term in set(re.findall(r"[\w\u0600-\u06ff]+", line.casefold()))]
positions = [line.casefold().find(term) for term in matching_terms if term]
if len(line) > max_chars and positions:
start = max(0, positions[0] - max_chars // 3)
line = ("…" if start else "") + line[start : start + max_chars - 32] + ("…" if start + max_chars - 32 < len(lines[index]) else "")
return f"{index + 1}: {line}"[:max_chars]
return "\n…\n".join(
f"{line_number + 1}: {lines[line_number]}"
for line_number in sorted(selected)
)[:max_chars]
def retrieve(task: str, root: Path, limit: int = 3) -> list[tuple[str, str]]:
terms = {
term.casefold()
@@ -484,8 +530,26 @@ def retrieve(task: str, root: Path, limit: int = 3) -> list[tuple[str, str]]:
"the", "and", "for", "with", "this", "that", "من", "على", "في", "عن",
"كيف", "شو", "ما", "ماذا", "هذا", "هذه", "التي", "الذي", "اشرح", "دور",
"ملف", "ملفات", "اذكر", "المستخدمة", "المستخدم", "المشروع", "التفسير",
"استخدم", "أداة", "البحث", "مساحة", "العمل", "مرات", "منفصلة", "للعثور",
"بعد", "كل", "ثم", "افحص", "النتيجة", "لخص", "اشرح", "اذكر", "علاقة",
}
}
term_expansions = {
"مهلة": {"timeout", "timeouts"},
"عميل": {"client"},
"مسار": {"path", "uri", "route"},
"بث": {"stream"},
"تدفق": {"stream"},
"واجهة": {"api", "endpoint"},
}
terms.update(
alias
for term in tuple(terms)
for alias in term_expansions.get(term, set())
)
code_search = bool(
re.search(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+|run/stream|\b(?:api|http|flutter|timeout)\b", task, re.IGNORECASE)
)
mentioned_paths = {
match.replace("\\", "/").casefold()
for match in re.findall(
@@ -508,11 +572,25 @@ def retrieve(task: str, root: Path, limit: int = 3) -> list[tuple[str, str]]:
relative = path.relative_to(root).as_posix()
if relative.casefold() in mentioned_paths:
score += 100_000
if code_search:
if path.suffix.casefold() in {".py", ".dart", ".js", ".ts", ".tsx", ".jsx"}:
score += 30
if "flutter" in terms and path.suffix.casefold() == ".dart":
score += 60
if path.suffix.casefold() in {".md", ".txt"}:
score = max(1, score - 30)
if any(part.casefold() in {"test", "tests"} for part in path.parts) and not any(
term in terms for term in {"test", "tests", "اختبار", "اختبارات"}
):
score = max(1, score - 30)
if score:
# If the user named a source file explicitly, include a larger bounded
# excerpt so the agent can explain the code rather than just locate it.
excerpt_limit = 12_000 if relative.casefold() in mentioned_paths else 1_800
ranked.append((score, relative, text[:excerpt_limit]))
# Explicit paths need enough context for explanation; search results
# should show the matching lines instead of an unrelated file prefix.
if relative.casefold() in mentioned_paths:
excerpt = text[:12_000]
else:
excerpt = _matching_excerpt(text, terms, 1_800)
ranked.append((score, relative, excerpt))
ranked.sort(key=lambda item: (-item[0], item[1]))
explicit_matches = [
item for item in ranked if item[1].casefold() in mentioned_paths