Sisyphus agent recreated in LangChain to figure out how it works and how to use it
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tool definitions for Sisyphus agents."""
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Filesystem tools for Sisyphus agents.
|
||||
|
||||
These are the LangChain equivalents of Loki's global tools:
|
||||
- fs_read.sh → read_file
|
||||
- fs_grep.sh → search_content
|
||||
- fs_glob.sh → search_files
|
||||
- fs_ls.sh → list_directory
|
||||
- fs_write.sh → write_file
|
||||
- fs_patch.sh → (omitted — write_file covers full rewrites)
|
||||
|
||||
Loki Concept Mapping:
|
||||
Loki tools are bash scripts with @cmd annotations that Loki's compiler
|
||||
turns into function-calling declarations. In LangChain, we use the @tool
|
||||
decorator which serves the same purpose: it generates the JSON schema
|
||||
that the LLM sees, and wraps the Python function for execution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
|
||||
@tool
|
||||
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
|
||||
"""Read a file's contents with optional line range.
|
||||
|
||||
Args:
|
||||
path: Path to the file (absolute or relative to cwd).
|
||||
offset: 1-based line number to start from.
|
||||
limit: Maximum number of lines to return.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isfile(path):
|
||||
return f"Error: file not found: {path}"
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
return f"Error reading {path}: {e}"
|
||||
|
||||
total = len(lines)
|
||||
start = max(0, offset - 1)
|
||||
end = min(total, start + limit)
|
||||
selected = lines[start:end]
|
||||
|
||||
result = f"File: {path} (lines {start + 1}-{end} of {total})\n\n"
|
||||
for i, line in enumerate(selected, start=start + 1):
|
||||
result += f"{i}: {line}"
|
||||
|
||||
if end < total:
|
||||
result += f"\n... truncated ({total} total lines)"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@tool
|
||||
def write_file(path: str, content: str) -> str:
|
||||
"""Write complete contents to a file, creating parent directories as needed.
|
||||
|
||||
Args:
|
||||
path: Path for the file.
|
||||
content: Complete file contents to write.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return f"Wrote: {path}"
|
||||
except Exception as e:
|
||||
return f"Error writing {path}: {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def search_content(pattern: str, directory: str = ".", file_type: str = "") -> str:
|
||||
"""Search for a text/regex pattern in files under a directory.
|
||||
|
||||
Args:
|
||||
pattern: Text or regex pattern to search for.
|
||||
directory: Root directory to search in.
|
||||
file_type: Optional file extension filter (e.g. "py", "rs").
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
cmd = ["grep", "-rn"]
|
||||
if file_type:
|
||||
cmd += [f"--include=*.{file_type}"]
|
||||
cmd += [pattern, directory]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
lines = result.stdout.strip().splitlines()
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
# Filter noise
|
||||
noise = {"/.git/", "/node_modules/", "/target/", "/dist/", "/__pycache__/"}
|
||||
filtered = [l for l in lines if not any(n in l for n in noise)][:30]
|
||||
|
||||
if not filtered:
|
||||
return "No matches found."
|
||||
return "\n".join(filtered)
|
||||
|
||||
|
||||
@tool
|
||||
def search_files(pattern: str, directory: str = ".") -> str:
|
||||
"""Find files matching a glob pattern.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern (e.g. '*.py', 'config*', '*test*').
|
||||
directory: Directory to search in.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
noise = {".git", "node_modules", "target", "dist", "__pycache__"}
|
||||
matches: list[str] = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in noise]
|
||||
for name in files:
|
||||
if fnmatch.fnmatch(name, pattern):
|
||||
matches.append(os.path.join(root, name))
|
||||
if len(matches) >= 25:
|
||||
break
|
||||
if len(matches) >= 25:
|
||||
break
|
||||
|
||||
if not matches:
|
||||
return "No files found."
|
||||
return "\n".join(matches)
|
||||
|
||||
|
||||
@tool
|
||||
def list_directory(path: str = ".", max_depth: int = 3) -> str:
|
||||
"""List directory tree structure.
|
||||
|
||||
Args:
|
||||
path: Directory to list.
|
||||
max_depth: Maximum depth to recurse.
|
||||
"""
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isdir(path):
|
||||
return f"Error: not a directory: {path}"
|
||||
|
||||
noise = {".git", "node_modules", "target", "dist", "__pycache__", ".venv", "venv"}
|
||||
lines: list[str] = []
|
||||
|
||||
def _walk(dir_path: str, prefix: str, depth: int) -> None:
|
||||
if depth > max_depth:
|
||||
return
|
||||
try:
|
||||
entries = sorted(os.listdir(dir_path))
|
||||
except PermissionError:
|
||||
return
|
||||
|
||||
dirs = [e for e in entries if os.path.isdir(os.path.join(dir_path, e)) and e not in noise]
|
||||
files = [e for e in entries if os.path.isfile(os.path.join(dir_path, e))]
|
||||
|
||||
for f in files[:20]:
|
||||
lines.append(f"{prefix}{f}")
|
||||
if len(files) > 20:
|
||||
lines.append(f"{prefix}... ({len(files) - 20} more files)")
|
||||
|
||||
for d in dirs:
|
||||
lines.append(f"{prefix}{d}/")
|
||||
_walk(os.path.join(dir_path, d), prefix + " ", depth + 1)
|
||||
|
||||
lines.append(f"{os.path.basename(path) or path}/")
|
||||
_walk(path, " ", 1)
|
||||
return "\n".join(lines[:200])
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Project detection and build/test tools.
|
||||
|
||||
These mirror Loki's .shared/utils.sh detect_project() heuristic and the
|
||||
sisyphus/coder tools.sh run_build / run_tests / verify_build commands.
|
||||
|
||||
Loki Concept Mapping:
|
||||
Loki uses a heuristic cascade: check for Cargo.toml → go.mod → package.json
|
||||
etc., then falls back to an LLM call for unknown projects. We replicate the
|
||||
heuristic portion here. The LLM fallback is omitted since the agents
|
||||
themselves can reason about unknown project types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from langchain_core.tools import tool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project detection (mirrors _detect_heuristic in utils.sh)
|
||||
# ---------------------------------------------------------------------------
|
||||
_HEURISTICS: list[tuple[str, dict[str, str]]] = [
|
||||
("Cargo.toml", {"type": "rust", "build": "cargo build", "test": "cargo test", "check": "cargo check"}),
|
||||
("go.mod", {"type": "go", "build": "go build ./...", "test": "go test ./...", "check": "go vet ./..."}),
|
||||
("package.json", {"type": "nodejs", "build": "npm run build", "test": "npm test", "check": "npm run lint"}),
|
||||
("pyproject.toml", {"type": "python", "build": "", "test": "pytest", "check": "ruff check ."}),
|
||||
("pom.xml", {"type": "java", "build": "mvn compile", "test": "mvn test", "check": "mvn verify"}),
|
||||
("Makefile", {"type": "make", "build": "make build", "test": "make test", "check": "make lint"}),
|
||||
]
|
||||
|
||||
|
||||
def detect_project(directory: str) -> dict[str, str]:
|
||||
"""Detect project type and return build/test commands."""
|
||||
for marker, info in _HEURISTICS:
|
||||
if os.path.exists(os.path.join(directory, marker)):
|
||||
return info
|
||||
return {"type": "unknown", "build": "", "test": "", "check": ""}
|
||||
|
||||
|
||||
@tool
|
||||
def get_project_info(directory: str = ".") -> str:
|
||||
"""Detect the project type and show structure overview.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
info = detect_project(directory)
|
||||
result = f"Project: {os.path.abspath(directory)}\n"
|
||||
result += f"Type: {info['type']}\n"
|
||||
result += f"Build: {info['build'] or '(none)'}\n"
|
||||
result += f"Test: {info['test'] or '(none)'}\n"
|
||||
result += f"Check: {info['check'] or '(none)'}\n"
|
||||
return result
|
||||
|
||||
|
||||
def _run_project_command(directory: str, command_key: str) -> str:
|
||||
"""Run a detected project command (build/test/check)."""
|
||||
directory = os.path.expanduser(directory)
|
||||
info = detect_project(directory)
|
||||
cmd = info.get(command_key, "")
|
||||
|
||||
if not cmd:
|
||||
return f"No {command_key} command detected for this project."
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
timeout=300,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
status = "SUCCESS" if result.returncode == 0 else f"FAILED (exit {result.returncode})"
|
||||
return f"Running: {cmd}\n\n{output}\n\n{command_key.upper()}: {status}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{command_key.upper()}: TIMEOUT after 300s"
|
||||
except Exception as e:
|
||||
return f"{command_key.upper()}: ERROR — {e}"
|
||||
|
||||
|
||||
@tool
|
||||
def run_build(directory: str = ".") -> str:
|
||||
"""Run the project's build command.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "build")
|
||||
|
||||
|
||||
@tool
|
||||
def run_tests(directory: str = ".") -> str:
|
||||
"""Run the project's test suite.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "test")
|
||||
|
||||
|
||||
@tool
|
||||
def verify_build(directory: str = ".") -> str:
|
||||
"""Run the project's check/lint command to verify correctness.
|
||||
|
||||
Args:
|
||||
directory: Project root directory.
|
||||
"""
|
||||
return _run_project_command(directory, "check")
|
||||
|
||||
|
||||
@tool
|
||||
def execute_command(command: str, directory: str = ".") -> str:
|
||||
"""Execute a shell command and return its output.
|
||||
|
||||
Args:
|
||||
command: Shell command to execute.
|
||||
directory: Working directory.
|
||||
"""
|
||||
directory = os.path.expanduser(directory)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=directory,
|
||||
timeout=120,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
if result.returncode != 0:
|
||||
return f"Command failed (exit {result.returncode}):\n{output}"
|
||||
return output or "(no output)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "Command timed out after 120s."
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
Reference in New Issue
Block a user