aru-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aru/__init__.py +1 -0
- aru/agents/__init__.py +0 -0
- aru/agents/base.py +188 -0
- aru/agents/executor.py +32 -0
- aru/agents/planner.py +85 -0
- aru/cli.py +1993 -0
- aru/config.py +237 -0
- aru/context.py +287 -0
- aru/providers.py +433 -0
- aru/tools/__init__.py +0 -0
- aru/tools/ast_tools.py +422 -0
- aru/tools/codebase.py +1328 -0
- aru/tools/gitignore.py +109 -0
- aru/tools/mcp_client.py +156 -0
- aru/tools/ranker.py +220 -0
- aru/tools/tasklist.py +183 -0
- aru_code-0.1.0.dist-info/METADATA +385 -0
- aru_code-0.1.0.dist-info/RECORD +22 -0
- aru_code-0.1.0.dist-info/WHEEL +5 -0
- aru_code-0.1.0.dist-info/entry_points.txt +2 -0
- aru_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- aru_code-0.1.0.dist-info/top_level.txt +1 -0
aru/tools/gitignore.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Gitignore-aware file filtering for codebase operations."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
|
|
6
|
+
import pathspec
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_path(path: str) -> str:
|
|
10
|
+
"""Convert backslashes to forward slashes and remove trailing slashes."""
|
|
11
|
+
return path.replace("\\", "/").rstrip("/")
|
|
12
|
+
|
|
13
|
+
# Hardcoded fallback patterns (always excluded even without .gitignore)
|
|
14
|
+
_FALLBACK_PATTERNS = [
|
|
15
|
+
".git",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
"venv",
|
|
19
|
+
".venv",
|
|
20
|
+
".aru",
|
|
21
|
+
"*.pyc",
|
|
22
|
+
"*.pyo",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Cache: {(root_dir, gitignore_mtime): PathSpec}
|
|
26
|
+
_cache: dict[tuple[str, float], pathspec.PathSpec] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_git_root(start: str) -> str | None:
|
|
30
|
+
"""Walk up from start directory to find the git root (directory containing .git)."""
|
|
31
|
+
current = os.path.abspath(start)
|
|
32
|
+
while True:
|
|
33
|
+
if os.path.isdir(os.path.join(current, ".git")):
|
|
34
|
+
return current
|
|
35
|
+
parent = os.path.dirname(current)
|
|
36
|
+
if parent == current:
|
|
37
|
+
return None
|
|
38
|
+
current = parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_gitignore(root_dir: str) -> pathspec.PathSpec:
|
|
42
|
+
"""Parse .gitignore from root_dir combined with hardcoded fallback patterns.
|
|
43
|
+
|
|
44
|
+
Results are cached by root_dir and .gitignore mtime.
|
|
45
|
+
"""
|
|
46
|
+
root_dir = os.path.abspath(root_dir)
|
|
47
|
+
gitignore_path = os.path.join(root_dir, ".gitignore")
|
|
48
|
+
|
|
49
|
+
mtime = 0.0
|
|
50
|
+
if os.path.isfile(gitignore_path):
|
|
51
|
+
mtime = os.path.getmtime(gitignore_path)
|
|
52
|
+
|
|
53
|
+
cache_key = (root_dir, mtime)
|
|
54
|
+
if cache_key in _cache:
|
|
55
|
+
return _cache[cache_key]
|
|
56
|
+
|
|
57
|
+
# Clear old entries for this root_dir
|
|
58
|
+
_cache.pop(next((k for k in _cache if k[0] == root_dir), (None, None)), None)
|
|
59
|
+
|
|
60
|
+
patterns = list(_FALLBACK_PATTERNS)
|
|
61
|
+
if os.path.isfile(gitignore_path):
|
|
62
|
+
with open(gitignore_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
63
|
+
for line in f:
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if line and not line.startswith("#"):
|
|
66
|
+
patterns.append(line)
|
|
67
|
+
|
|
68
|
+
spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
69
|
+
_cache[cache_key] = spec
|
|
70
|
+
return spec
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_ignored(path: str, root_dir: str) -> bool:
|
|
74
|
+
"""Check if a relative path should be ignored based on .gitignore rules.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: Relative path to check (forward slashes preferred).
|
|
78
|
+
root_dir: Project root directory containing .gitignore.
|
|
79
|
+
"""
|
|
80
|
+
spec = load_gitignore(root_dir)
|
|
81
|
+
# Normalize to forward slashes for pathspec
|
|
82
|
+
normalized = path.replace("\\", "/")
|
|
83
|
+
return spec.match_file(normalized)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def walk_filtered(directory: str) -> Iterator[tuple[str, list[str], list[str]]]:
|
|
87
|
+
"""Walk directory tree, filtering out gitignored files and directories.
|
|
88
|
+
|
|
89
|
+
Drop-in replacement for os.walk() that respects .gitignore rules.
|
|
90
|
+
Finds the git root (or uses the directory itself) to load ignore patterns.
|
|
91
|
+
"""
|
|
92
|
+
directory = os.path.abspath(directory)
|
|
93
|
+
root_dir = _find_git_root(directory) or directory
|
|
94
|
+
spec = load_gitignore(root_dir)
|
|
95
|
+
|
|
96
|
+
for dirpath, dirs, files in os.walk(directory):
|
|
97
|
+
# Filter directories in-place to prevent descending into ignored dirs
|
|
98
|
+
dirs[:] = [
|
|
99
|
+
d for d in dirs
|
|
100
|
+
if not spec.match_file(os.path.relpath(os.path.join(dirpath, d), root_dir).replace("\\", "/") + "/")
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Filter files
|
|
104
|
+
filtered_files = [
|
|
105
|
+
f for f in files
|
|
106
|
+
if not spec.match_file(os.path.relpath(os.path.join(dirpath, f), root_dir).replace("\\", "/"))
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
yield dirpath, dirs, filtered_files
|
aru/tools/mcp_client.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Model Context Protocol (MCP) client manager and tool generation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import AsyncExitStack
|
|
7
|
+
|
|
8
|
+
from agno.tools import Function
|
|
9
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
10
|
+
from mcp.client.session import ClientSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class McpSessionManager:
|
|
14
|
+
"""Manages MCP server subprocesses and active client sessions."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config_path: str = "arc.mcp.json"):
|
|
17
|
+
self.config_path = config_path
|
|
18
|
+
self._exit_stack = AsyncExitStack()
|
|
19
|
+
self.sessions: dict[str, ClientSession] = {}
|
|
20
|
+
|
|
21
|
+
async def initialize(self):
|
|
22
|
+
"""Read config and spawn all MCP servers concurrently."""
|
|
23
|
+
if not os.path.exists(self.config_path):
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
with open(self.config_path, "r", encoding="utf-8") as f:
|
|
27
|
+
try:
|
|
28
|
+
config = json.load(f)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
print(f"[Warning] Failed to parse {self.config_path}")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
servers = config.get("mcpServers", {})
|
|
34
|
+
tasks = []
|
|
35
|
+
for name, svr_config in servers.items():
|
|
36
|
+
cmd = svr_config.get("command")
|
|
37
|
+
if not cmd:
|
|
38
|
+
continue
|
|
39
|
+
tasks.append(self._start_server(name, svr_config))
|
|
40
|
+
|
|
41
|
+
if tasks:
|
|
42
|
+
await asyncio.gather(*tasks)
|
|
43
|
+
|
|
44
|
+
async def _start_server(self, name: str, svr_config: dict):
|
|
45
|
+
"""Start a single MCP server and register its session."""
|
|
46
|
+
cmd = svr_config.get("command")
|
|
47
|
+
args = svr_config.get("args", [])
|
|
48
|
+
env = svr_config.get("env", None)
|
|
49
|
+
|
|
50
|
+
server_params = StdioServerParameters(
|
|
51
|
+
command=cmd,
|
|
52
|
+
args=args,
|
|
53
|
+
env={**os.environ.copy(), **env} if env else None
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
read_stream, write_stream = await self._exit_stack.enter_async_context(
|
|
58
|
+
stdio_client(server_params)
|
|
59
|
+
)
|
|
60
|
+
session = await self._exit_stack.enter_async_context(
|
|
61
|
+
ClientSession(read_stream, write_stream)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
await session.initialize()
|
|
65
|
+
self.sessions[name] = session
|
|
66
|
+
except Exception as e:
|
|
67
|
+
print(f"[Warning] Failed to start MCP server '{name}': {e}")
|
|
68
|
+
|
|
69
|
+
async def get_tools(self) -> list[Function]:
|
|
70
|
+
"""Fetch all tools from connected servers concurrently and convert to Agno Functions."""
|
|
71
|
+
|
|
72
|
+
async def _fetch(server_name: str, session: ClientSession) -> list[Function]:
|
|
73
|
+
try:
|
|
74
|
+
result = await session.list_tools()
|
|
75
|
+
return [self._create_agno_function(server_name, session, tool) for tool in result.tools]
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f"[Warning] Failed to fetch tools from MCP server '{server_name}': {e}")
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
results = await asyncio.gather(
|
|
81
|
+
*[_fetch(name, sess) for name, sess in self.sessions.items()]
|
|
82
|
+
)
|
|
83
|
+
return [tool for tools in results for tool in tools]
|
|
84
|
+
|
|
85
|
+
def _create_agno_function(self, server_name: str, session: ClientSession, tool) -> Function:
|
|
86
|
+
"""Dynamically create an Agno Function that routes to the remote MCP tool."""
|
|
87
|
+
|
|
88
|
+
# We need to capture 'session' and 'tool.name' cleanly.
|
|
89
|
+
# Python's default arguments trick captures loop variables.
|
|
90
|
+
async def mcp_caller(**kwargs) -> str:
|
|
91
|
+
try:
|
|
92
|
+
result = await session.call_tool(tool.name, arguments=kwargs)
|
|
93
|
+
# Parse MCP ToolResultContent
|
|
94
|
+
output = []
|
|
95
|
+
for content in result.content:
|
|
96
|
+
if hasattr(content, "text"):
|
|
97
|
+
output.append(content.text)
|
|
98
|
+
if result.isError:
|
|
99
|
+
return f"Error from {tool.name}: " + "\n".join(output)
|
|
100
|
+
return "\n".join(output)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return f"Error executing {tool.name} on {server_name}: {e}"
|
|
103
|
+
|
|
104
|
+
# Assign __name__ to the callable for Agno's internal representation
|
|
105
|
+
safe_name = f"{server_name}__{tool.name}".replace("-", "_")
|
|
106
|
+
mcp_caller.__name__ = safe_name
|
|
107
|
+
|
|
108
|
+
return Function(
|
|
109
|
+
name=safe_name,
|
|
110
|
+
description=f"[{server_name}] {tool.description or ''}",
|
|
111
|
+
parameters=tool.inputSchema,
|
|
112
|
+
entrypoint=mcp_caller
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def cleanup(self):
|
|
116
|
+
"""Close all active MCP client sessions and terminate server subprocesses."""
|
|
117
|
+
try:
|
|
118
|
+
await self._exit_stack.aclose()
|
|
119
|
+
except (RuntimeError, Exception):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Global Singleton manager to be used entirely inside aru's async loops
|
|
124
|
+
_manager: McpSessionManager | None = None
|
|
125
|
+
|
|
126
|
+
async def init_mcp() -> list[Function]:
|
|
127
|
+
"""Initialize MCP servers and return the loaded Agno functions."""
|
|
128
|
+
global _manager
|
|
129
|
+
if _manager is None:
|
|
130
|
+
config_path = None
|
|
131
|
+
for path in [
|
|
132
|
+
".aru/mcp_servers.json",
|
|
133
|
+
"aru.mcp.json",
|
|
134
|
+
".mcp.json",
|
|
135
|
+
"mcp.json"
|
|
136
|
+
]:
|
|
137
|
+
if os.path.exists(path):
|
|
138
|
+
config_path = path
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if config_path:
|
|
142
|
+
_manager = McpSessionManager(config_path=config_path)
|
|
143
|
+
await _manager.initialize()
|
|
144
|
+
else:
|
|
145
|
+
# Create an empty manager so cleanup doesn't fail, but return no tools
|
|
146
|
+
_manager = McpSessionManager(config_path="")
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
return await _manager.get_tools()
|
|
150
|
+
|
|
151
|
+
async def cleanup_mcp():
|
|
152
|
+
"""Cleanup global manager."""
|
|
153
|
+
global _manager
|
|
154
|
+
if _manager:
|
|
155
|
+
await _manager.cleanup()
|
|
156
|
+
_manager = None
|
aru/tools/ranker.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Multi-factor file relevance ranking for task-driven context selection."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from aru.tools.gitignore import walk_filtered
|
|
8
|
+
|
|
9
|
+
# Weights for each ranking signal (sum to 1.0)
|
|
10
|
+
WEIGHT_NAME = 0.50
|
|
11
|
+
WEIGHT_STRUCTURAL = 0.30
|
|
12
|
+
WEIGHT_RECENCY = 0.20
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_project_files(root_dir: str) -> list[str]:
|
|
16
|
+
"""Get all project files using gitignore-aware walk."""
|
|
17
|
+
files = []
|
|
18
|
+
for dirpath, _, filenames in walk_filtered(root_dir):
|
|
19
|
+
for filename in filenames:
|
|
20
|
+
filepath = os.path.join(dirpath, filename)
|
|
21
|
+
rel_path = os.path.relpath(filepath, root_dir).replace("\\", "/")
|
|
22
|
+
files.append(rel_path)
|
|
23
|
+
return files
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _score_name_match(file_path: str, keywords: list[str]) -> float:
|
|
27
|
+
"""Score based on how many task keywords appear in the file path/name."""
|
|
28
|
+
if not keywords:
|
|
29
|
+
return 0.0
|
|
30
|
+
|
|
31
|
+
path_lower = file_path.lower()
|
|
32
|
+
# Split path into components for matching
|
|
33
|
+
path_parts = re.split(r"[/\\_.\-]", path_lower)
|
|
34
|
+
|
|
35
|
+
matches = 0
|
|
36
|
+
for keyword in keywords:
|
|
37
|
+
kw = keyword.lower()
|
|
38
|
+
if len(kw) < 3: # Skip very short words
|
|
39
|
+
continue
|
|
40
|
+
# Exact match in path component
|
|
41
|
+
if kw in path_parts:
|
|
42
|
+
matches += 2
|
|
43
|
+
# Partial match in full path
|
|
44
|
+
elif kw in path_lower:
|
|
45
|
+
matches += 1
|
|
46
|
+
# Check if any path component is a substring of the keyword (e.g., "auth" in "authentication")
|
|
47
|
+
else:
|
|
48
|
+
for part in path_parts:
|
|
49
|
+
if len(part) >= 3 and part in kw:
|
|
50
|
+
matches += 1.5 # Higher than partial match, lower than exact
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
return min(matches / max(len(keywords), 1), 1.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_keywords(task: str) -> list[str]:
|
|
57
|
+
"""Extract meaningful keywords from a task description."""
|
|
58
|
+
# Common stop words to filter out
|
|
59
|
+
stop_words = {
|
|
60
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
61
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
62
|
+
"should", "may", "might", "can", "shall", "to", "of", "in", "for",
|
|
63
|
+
"on", "with", "at", "by", "from", "as", "into", "through", "during",
|
|
64
|
+
"before", "after", "above", "below", "between", "out", "off", "over",
|
|
65
|
+
"under", "again", "further", "then", "once", "here", "there", "when",
|
|
66
|
+
"where", "why", "how", "all", "each", "every", "both", "few", "more",
|
|
67
|
+
"most", "other", "some", "such", "no", "nor", "not", "only", "own",
|
|
68
|
+
"same", "so", "than", "too", "very", "just", "but", "and", "or",
|
|
69
|
+
"if", "it", "its", "this", "that", "these", "those", "i", "me", "my",
|
|
70
|
+
"we", "our", "you", "your", "he", "she", "they", "them", "what",
|
|
71
|
+
"which", "who", "whom", "add", "create", "make", "build", "implement",
|
|
72
|
+
"fix", "update", "change", "modify", "remove", "delete", "get", "set",
|
|
73
|
+
"use", "new", "file", "files", "code", "function", "method",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Tokenize and filter
|
|
77
|
+
words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", task)
|
|
78
|
+
keywords = [w for w in words if w.lower() not in stop_words and len(w) >= 3]
|
|
79
|
+
return keywords
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _score_recency(file_path: str, root_dir: str, max_age_days: float = 30.0) -> float:
|
|
83
|
+
"""Score based on how recently the file was modified (0-1, 1 = most recent)."""
|
|
84
|
+
try:
|
|
85
|
+
mtime = os.path.getmtime(os.path.join(root_dir, file_path))
|
|
86
|
+
import time
|
|
87
|
+
age_seconds = time.time() - mtime
|
|
88
|
+
age_days = age_seconds / 86400
|
|
89
|
+
if age_days <= 0:
|
|
90
|
+
return 1.0
|
|
91
|
+
if age_days >= max_age_days:
|
|
92
|
+
return 0.0
|
|
93
|
+
return 1.0 - (age_days / max_age_days)
|
|
94
|
+
except OSError:
|
|
95
|
+
return 0.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_structural_scores(top_files: list[str], root_dir: str) -> dict[str, float]:
|
|
99
|
+
"""Boost files that are dependencies of already-relevant files."""
|
|
100
|
+
try:
|
|
101
|
+
from aru.tools.ast_tools import find_dependencies, _resolve_import_to_file, _find_project_root
|
|
102
|
+
except ImportError:
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
dep_counts: dict[str, int] = {}
|
|
106
|
+
|
|
107
|
+
for file_path in top_files[:5]: # Only trace top 5 to avoid slowness
|
|
108
|
+
full_path = os.path.join(root_dir, file_path)
|
|
109
|
+
if not os.path.isfile(full_path):
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
114
|
+
content = f.read()
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Extract imports and resolve to local files
|
|
119
|
+
for line in content.split("\n"):
|
|
120
|
+
stripped = line.strip()
|
|
121
|
+
if stripped.startswith("import ") or stripped.startswith("from "):
|
|
122
|
+
resolved = _resolve_import_to_file(stripped, root_dir)
|
|
123
|
+
if resolved:
|
|
124
|
+
normalized = resolved.replace("\\", "/")
|
|
125
|
+
dep_counts[normalized] = dep_counts.get(normalized, 0) + 1
|
|
126
|
+
|
|
127
|
+
if not dep_counts:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
max_count = max(dep_counts.values())
|
|
131
|
+
return {k: v / max_count for k, v in dep_counts.items()}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def rank_files(task: str, top_k: int = 15) -> str:
|
|
135
|
+
"""Rank project files by relevance to a given task description.
|
|
136
|
+
|
|
137
|
+
Uses multiple signals to determine which files are most relevant:
|
|
138
|
+
- Filename/path keyword matching
|
|
139
|
+
- Structural dependencies (files imported by relevant files)
|
|
140
|
+
- Modification recency
|
|
141
|
+
|
|
142
|
+
Use this as a first step when starting a new task to identify which files to read.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
task: Natural language description of the task (e.g. "add authentication to the CLI").
|
|
146
|
+
top_k: Maximum number of files to return. Defaults to 15.
|
|
147
|
+
"""
|
|
148
|
+
root_dir = os.getcwd()
|
|
149
|
+
all_files = _get_project_files(root_dir)
|
|
150
|
+
|
|
151
|
+
if not all_files:
|
|
152
|
+
return "No files found in the project."
|
|
153
|
+
|
|
154
|
+
keywords = _extract_keywords(task)
|
|
155
|
+
|
|
156
|
+
# Signal 1: Name match scores
|
|
157
|
+
name_scores = {f: _score_name_match(f, keywords) for f in all_files}
|
|
158
|
+
|
|
159
|
+
# Signal 2: Recency scores
|
|
160
|
+
recency_scores = {f: _score_recency(f, root_dir) for f in all_files}
|
|
161
|
+
|
|
162
|
+
# Preliminary ranking (without structural) to find top files for dependency tracing
|
|
163
|
+
preliminary_scores = {}
|
|
164
|
+
for f in all_files:
|
|
165
|
+
score = (
|
|
166
|
+
WEIGHT_NAME * name_scores.get(f, 0.0)
|
|
167
|
+
+ WEIGHT_RECENCY * recency_scores.get(f, 0.0)
|
|
168
|
+
)
|
|
169
|
+
preliminary_scores[f] = score
|
|
170
|
+
|
|
171
|
+
# Signal 3: Structural scores (based on top preliminary results)
|
|
172
|
+
top_preliminary = sorted(preliminary_scores, key=preliminary_scores.get, reverse=True)[:10]
|
|
173
|
+
structural_scores = _get_structural_scores(top_preliminary, root_dir)
|
|
174
|
+
|
|
175
|
+
# Final combined scores
|
|
176
|
+
final_scores: dict[str, tuple[float, list[str]]] = {}
|
|
177
|
+
for f in all_files:
|
|
178
|
+
reasons = []
|
|
179
|
+
name = name_scores.get(f, 0.0)
|
|
180
|
+
structural = structural_scores.get(f, 0.0)
|
|
181
|
+
recency = recency_scores.get(f, 0.0)
|
|
182
|
+
|
|
183
|
+
score = (
|
|
184
|
+
WEIGHT_NAME * name
|
|
185
|
+
+ WEIGHT_STRUCTURAL * structural
|
|
186
|
+
+ WEIGHT_RECENCY * recency
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Build reason strings
|
|
190
|
+
if name > 0.3:
|
|
191
|
+
reasons.append("name match")
|
|
192
|
+
if structural > 0:
|
|
193
|
+
reasons.append("dependency of top files")
|
|
194
|
+
if recency > 0.7:
|
|
195
|
+
reasons.append("recently modified")
|
|
196
|
+
|
|
197
|
+
if score > 0:
|
|
198
|
+
final_scores[f] = (score, reasons)
|
|
199
|
+
|
|
200
|
+
# Sort and take top_k
|
|
201
|
+
ranked = sorted(final_scores.items(), key=lambda x: x[1][0], reverse=True)[:top_k]
|
|
202
|
+
|
|
203
|
+
if not ranked:
|
|
204
|
+
return f"No files found with relevance to: {task}"
|
|
205
|
+
|
|
206
|
+
# Normalize scores to 0-1 based on top score
|
|
207
|
+
max_score = ranked[0][1][0] if ranked else 1.0
|
|
208
|
+
if max_score == 0:
|
|
209
|
+
max_score = 1.0
|
|
210
|
+
|
|
211
|
+
# Format output
|
|
212
|
+
lines = [f"Files ranked by relevance to: \"{task}\"\n"]
|
|
213
|
+
lines.append("Ranking mode: name + structural + recency\n")
|
|
214
|
+
|
|
215
|
+
for i, (file_path, (score, reasons)) in enumerate(ranked, 1):
|
|
216
|
+
normalized_score = score / max_score
|
|
217
|
+
reason_str = " + ".join(reasons) if reasons else "low signal"
|
|
218
|
+
lines.append(f" {i:2d}. {file_path} ({normalized_score:.2f}) — {reason_str}")
|
|
219
|
+
|
|
220
|
+
return "\n".join(lines)
|
aru/tools/tasklist.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Task list tools for structured step execution.
|
|
2
|
+
|
|
3
|
+
Provides create_task_list and update_task tools that the executor must call
|
|
4
|
+
to plan and track subtasks within each plan step. Inspired by Claude Code
|
|
5
|
+
and Antigravity's task management approach.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
from rich.console import Console, Group
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
_console = Console()
|
|
15
|
+
_live = None
|
|
16
|
+
_display = None
|
|
17
|
+
|
|
18
|
+
MAX_SUBTASKS = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_live(live):
|
|
22
|
+
global _live
|
|
23
|
+
_live = live
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def set_display(display):
|
|
27
|
+
global _display
|
|
28
|
+
_display = display
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TaskStore:
|
|
32
|
+
"""Thread-safe store for the current step's subtask list."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._tasks: list[dict] = [] # {"index": int, "description": str, "status": str}
|
|
37
|
+
self._created = False
|
|
38
|
+
|
|
39
|
+
def create(self, tasks: list[str]) -> list[dict]:
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._tasks = [
|
|
42
|
+
{"index": i + 1, "description": desc, "status": "pending"}
|
|
43
|
+
for i, desc in enumerate(tasks)
|
|
44
|
+
]
|
|
45
|
+
self._created = True
|
|
46
|
+
return list(self._tasks)
|
|
47
|
+
|
|
48
|
+
def update(self, index: int, status: str) -> dict | None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
for task in self._tasks:
|
|
51
|
+
if task["index"] == index:
|
|
52
|
+
task["status"] = status
|
|
53
|
+
return dict(task)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def get_all(self) -> list[dict]:
|
|
57
|
+
with self._lock:
|
|
58
|
+
return list(self._tasks)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_created(self) -> bool:
|
|
62
|
+
with self._lock:
|
|
63
|
+
return self._created
|
|
64
|
+
|
|
65
|
+
def reset(self):
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._tasks = []
|
|
68
|
+
self._created = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Global singleton per executor step (reset between steps)
|
|
72
|
+
_store = _TaskStore()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reset_task_store():
|
|
76
|
+
"""Reset the task store between executor steps."""
|
|
77
|
+
_store.reset()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_task_store() -> _TaskStore:
|
|
81
|
+
"""Get the current task store for inspection."""
|
|
82
|
+
return _store
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_task_list(tasks: list[dict]) -> Panel:
|
|
86
|
+
"""Render the task list as a Rich panel."""
|
|
87
|
+
lines = []
|
|
88
|
+
for t in tasks:
|
|
89
|
+
if t["status"] == "completed":
|
|
90
|
+
icon = "[bold green]✓[/bold green]"
|
|
91
|
+
style = "dim"
|
|
92
|
+
elif t["status"] == "in_progress":
|
|
93
|
+
icon = "[bold yellow]~[/bold yellow]"
|
|
94
|
+
style = "bold"
|
|
95
|
+
elif t["status"] == "failed":
|
|
96
|
+
icon = "[bold red]✗[/bold red]"
|
|
97
|
+
style = "red"
|
|
98
|
+
else:
|
|
99
|
+
icon = "[dim]○[/dim]"
|
|
100
|
+
style = "dim"
|
|
101
|
+
lines.append(Text.from_markup(f" {icon} {t['index']}. {t['description']}", style=style))
|
|
102
|
+
|
|
103
|
+
return Panel(
|
|
104
|
+
Group(*lines),
|
|
105
|
+
title="[bold cyan]Subtasks[/bold cyan]",
|
|
106
|
+
border_style="cyan",
|
|
107
|
+
expand=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _show(panel: Panel):
|
|
112
|
+
"""Display panel using the active display or console."""
|
|
113
|
+
if _display and hasattr(_display, "show_permission"):
|
|
114
|
+
_display.show_permission(panel)
|
|
115
|
+
elif _live:
|
|
116
|
+
_live.console.print(panel)
|
|
117
|
+
else:
|
|
118
|
+
_console.print(panel)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def create_task_list(tasks: list[str]) -> str:
|
|
122
|
+
"""Create a subtask list for the current step. MUST be called before any other tool.
|
|
123
|
+
|
|
124
|
+
Define 1-10 concrete subtasks that you will execute in order.
|
|
125
|
+
Each subtask should be a single action (Read, Write, Edit, Run).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
tasks: List of subtask descriptions. Min 1, max 10.
|
|
129
|
+
Example: ["Read backend/models.py", "Write backend/auth.py", "Edit backend/main.py — add import", "Run pytest"]
|
|
130
|
+
"""
|
|
131
|
+
if _store.is_created:
|
|
132
|
+
return "Error: Task list already created for this step. Use update_task to update subtask status."
|
|
133
|
+
|
|
134
|
+
if len(tasks) < 1:
|
|
135
|
+
return "Error: Minimum 1 subtask required."
|
|
136
|
+
|
|
137
|
+
if len(tasks) > MAX_SUBTASKS:
|
|
138
|
+
return f"Error: Maximum {MAX_SUBTASKS} subtasks allowed. Got {len(tasks)}. Simplify your plan."
|
|
139
|
+
|
|
140
|
+
created = _store.create(tasks)
|
|
141
|
+
panel = _render_task_list(created)
|
|
142
|
+
_show(panel)
|
|
143
|
+
|
|
144
|
+
task_lines = "\n".join(f" {t['index']}. {t['description']}" for t in created)
|
|
145
|
+
return f"Task list created ({len(created)} subtasks):\n{task_lines}\n\nNow execute subtask 1."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def update_task(index: int, status: str) -> str:
|
|
149
|
+
"""Update the status of a subtask. Call this as you complete each subtask.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
index: Subtask number (1-based).
|
|
153
|
+
status: New status — one of: "in_progress", "completed", "failed".
|
|
154
|
+
"""
|
|
155
|
+
if not _store.is_created:
|
|
156
|
+
return "Error: No task list exists. Call create_task_list first."
|
|
157
|
+
|
|
158
|
+
if status not in ("in_progress", "completed", "failed"):
|
|
159
|
+
return f"Error: Invalid status '{status}'. Use: in_progress, completed, failed."
|
|
160
|
+
|
|
161
|
+
updated = _store.update(index, status)
|
|
162
|
+
if not updated:
|
|
163
|
+
return f"Error: Subtask {index} not found."
|
|
164
|
+
|
|
165
|
+
# Show updated task list
|
|
166
|
+
all_tasks = _store.get_all()
|
|
167
|
+
panel = _render_task_list(all_tasks)
|
|
168
|
+
_show(panel)
|
|
169
|
+
|
|
170
|
+
# Check if all done
|
|
171
|
+
completed_count = sum(1 for t in all_tasks if t["status"] == "completed")
|
|
172
|
+
failed_count = sum(1 for t in all_tasks if t["status"] == "failed")
|
|
173
|
+
total = len(all_tasks)
|
|
174
|
+
|
|
175
|
+
if completed_count + failed_count == total:
|
|
176
|
+
return f"All subtasks finished ({completed_count} completed, {failed_count} failed). Step done. Output a brief summary of what was created/changed."
|
|
177
|
+
|
|
178
|
+
# Find next pending subtask
|
|
179
|
+
next_task = next((t for t in all_tasks if t["status"] == "pending"), None)
|
|
180
|
+
if next_task:
|
|
181
|
+
return f"Subtask {index} → {status}. Next: subtask {next_task['index']} — {next_task['description']}"
|
|
182
|
+
|
|
183
|
+
return f"Subtask {index} → {status}."
|