Sisyphus agent recreated in LangChain to figure out how it works and how to use it

This commit is contained in:
2026-04-15 12:47:38 -06:00
parent ff3419a714
commit 9bab6a0c2d
14 changed files with 1745 additions and 0 deletions
@@ -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}"