diff --git a/examples/research_demo/agents.py b/examples/research_demo/agents.py index c78689e..765173c 100644 --- a/examples/research_demo/agents.py +++ b/examples/research_demo/agents.py @@ -1,14 +1,17 @@ """Agent configuration for the Research Demo application. -Multi-agent architecture with three agents: +Multi-agent architecture with four agents: - research_coordinator: Root agent that owns workflow state (planning, tasks, HITL) - and delegates to the arXiv specialist and data analyst. + and delegates to the arXiv specialist, data analyst, and report writer. - arxiv_specialist: Leaf agent focused on arXiv paper search, analysis, and ingestion. - data_analyst: Leaf agent that runs multi-step data analysis in a stateful executor. +- report_writer: Leaf agent that turns analysis artifacts into a compiled LaTeX PDF. Uses framework-provided tools exclusively — no app-specific tools needed. """ +from pathlib import Path + from agentic_cli.workflow import AgentConfig from agentic_cli.tools import ( memory_tools, @@ -26,6 +29,7 @@ diff_compare, grep, glob, + compile_document, ) from agentic_cli.tools.sandbox import sandbox_execute @@ -125,6 +129,40 @@ """ +# --------------------------------------------------------------------------- +# Report Writer (leaf agent) +# --------------------------------------------------------------------------- + +def report_writer_prompt() -> str: + """Build the prompt from the *active* settings, so the artifacts/build paths + follow whatever workspace is configured (default ~/.research_demo, a tmp dir + under test, or a user override) instead of a hard-coded path. AgentConfig + calls this (get_prompt()) at agent-assembly time, when settings are set.""" + from agentic_cli.config import get_settings + + settings = get_settings() + artifacts_dir = str( + settings.sandbox_outputs_dir or (Path(settings.workspace_dir) / "artifacts") + ) + reports_dir = str(Path(settings.workspace_dir) / "reports") + return f"""You are a report writer. You turn a completed data analysis into a compiled PDF report using LaTeX. + +## Where things are +- Analysis deliverables (figures as .png, tables as .csv) are in the artifacts directory: {artifacts_dir} +- Author your LaTeX source in the build directory: {reports_dir} +- Deliver the final PDF to: {artifacts_dir}/report.pdf + +## How to work +You have a `report-writer` skill — call `load_skill("report-writer")` for the report structure, the LaTeX template, the compile steps, and the error-recovery guide. In short: +1. `glob("{artifacts_dir}/*.png")` to learn the exact figure filenames. +2. Load the template, fill it in, and `write_file` it to {reports_dir}/report.tex (reference figures by BARE filename). +3. Compile with `compile_document(source_path="{reports_dir}/report.tex", output_pdf="{artifacts_dir}/report.pdf", assets_dir="{artifacts_dir}")`. +4. If it fails, read the returned errors/log_tail, fix the .tex, and recompile (at most 3 tries). + +Report the final PDF path to the user. You only use these tools — you do not run arbitrary code. +""" + + # --------------------------------------------------------------------------- # Research Coordinator (root agent) # --------------------------------------------------------------------------- @@ -178,6 +216,7 @@ 7. **WAIT for user confirmation** before executing tasks. 8. For arXiv paper research, **delegate to arxiv_specialist** (it has KB writer access and writes concept pages when 3+ related papers accumulate). - For multi-step data analysis (datasets, DataFrames, plots), delegate to **data_analyst**. Use `execute_python` only for quick one-off calculations. +- To produce a written/PDF **report** of a completed analysis, delegate to **report_writer** (it compiles a LaTeX report from the analysis artifacts). 9. Execute ONE task at a time, updating the plan after each. 10. Use `web_fetch` to extract information from specific URLs found during research. 11. Use `execute_python` for quick calculations and data validation. @@ -248,6 +287,15 @@ tools=[sandbox_execute, read_file, write_file, ask_clarification], description="Stateful data-analysis specialist: loads datasets and runs multi-step pandas/plotting analysis in an isolated executor.", ), + # Leaf agent: report writer (must be listed before coordinator) + AgentConfig( + name="report_writer", + prompt=report_writer_prompt, + include_state_tools=False, + tools=[write_file, read_file, glob, compile_document, ask_clarification], + skills=["report-writer"], + description="Report writer: turns the analysis figures/tables in the artifacts dir into a compiled LaTeX PDF report.", + ), # Root agent: research coordinator (owns workflow state, delegates arXiv work) AgentConfig( name="research_coordinator", @@ -273,7 +321,7 @@ grep, diff_compare, ], - sub_agents=["arxiv_specialist", "data_analyst"], + sub_agents=["arxiv_specialist", "data_analyst", "report_writer"], description="Research coordinator with memory, planning, task management, knowledge base, and HITL capabilities", ), ] diff --git a/examples/research_demo/settings.py b/examples/research_demo/settings.py index 12b922c..9f58c4d 100644 --- a/examples/research_demo/settings.py +++ b/examples/research_demo/settings.py @@ -47,3 +47,7 @@ def model_post_init(self, __context): object.__setattr__(self, "sandbox_data_mounts", [f"{data_dir}:samples"]) if "sandbox_outputs_dir" not in self.model_fields_set: object.__setattr__(self, "sandbox_outputs_dir", str(Path(self.workspace_dir) / "artifacts")) + if "skills_dirs" not in self.model_fields_set: + object.__setattr__( + self, "skills_dirs", [str(Path(__file__).parent / "skills")] + ) diff --git a/examples/research_demo/skills/report-writer/SKILL.md b/examples/research_demo/skills/report-writer/SKILL.md new file mode 100644 index 0000000..c84820c --- /dev/null +++ b/examples/research_demo/skills/report-writer/SKILL.md @@ -0,0 +1,62 @@ +--- +name: report-writer +description: Write and compile a LaTeX analysis report to PDF from figures and tables already produced in the run's artifacts directory. Use when the user wants a written or PDF report of a completed analysis. +--- + +# report-writer + +Produce a compiled PDF analysis report with LaTeX. You author a `.tex` file and +compile it with the `compile_document` tool. You do NOT run arbitrary code. + +## Inputs + +Analysis deliverables (figures as `.png`, tables as `.csv`) live in the run's +**artifacts directory** — the concrete path is in your instructions. Before +writing, list it with `glob` to learn the exact figure filenames: + + glob("/*.png") + +Reference figures by **bare filename** (e.g. `accuracy.png`), never full paths — +`compile_document`'s `assets_dir` makes them resolvable. + +## Report structure + +Write these sections, in order: + +1. **Introduction** — the question and why it matters. +2. **Methods** — the data and how it was analyzed. +3. **Results** — the findings, with figures/tables embedded and referenced. +4. **Discussion** — what the results mean; limitations. +5. **References** — a plain list of sources (no citation engine). + +## Authoring + +1. Load the template: `load_skill_resource("report-writer", "assets/report_template.tex")`. +2. Fill the placeholders (title, author/date, section prose, figure includes, table rows). +3. Escape LaTeX specials in prose: `% & _ # $ { }` → `\% \& \_ \# \$ \{ \}`. +4. `write_file` the filled source to the build directory as `report.tex`. + +## Compile + +Call the tool: + + compile_document( + source_path="/report.tex", + output_pdf="/report.pdf", + assets_dir="", + ) + +- On `success: true`, tell the user the report is at `pdf_path`. Done. +- On `success: false`, read `errors` and `log_tail`, fix the `.tex`, and recompile. + **Retry at most 3 times**, then report the failure with the error if still failing. + +## Common errors → fixes + +- `! Undefined control sequence` — a command from a package you did not + `\usepackage`. Add the package or use a base-LaTeX equivalent. +- `! LaTeX Error: File '' not found` — a figure name is wrong; re-`glob` + the artifacts dir and use the exact filename. +- `! Missing $ inserted` / `! You can't use ...` — an unescaped special + character in prose; escape it. +- `No LaTeX engine on PATH` — the host has no TeX install; tell the user to + install TeX Live or MacTeX. You cannot compile without it. diff --git a/examples/research_demo/skills/report-writer/assets/report_template.tex b/examples/research_demo/skills/report-writer/assets/report_template.tex new file mode 100644 index 0000000..6aa7860 --- /dev/null +++ b/examples/research_demo/skills/report-writer/assets/report_template.tex @@ -0,0 +1,52 @@ +\documentclass[11pt]{article} +\usepackage{graphicx} + +% === Fill in: title / author / date === +\title{TITLE_PLACEHOLDER} +\author{AUTHOR_PLACEHOLDER} +\date{DATE_PLACEHOLDER} + +\begin{document} +\maketitle + +\section{Introduction} +% Fill in: the question and why it matters. +INTRO_PLACEHOLDER + +\section{Methods} +% Fill in: the data and how it was analyzed. +METHODS_PLACEHOLDER + +\section{Results} +% Fill in: findings. Embed figures by BARE filename, e.g.: +% \begin{figure}[h] +% \centering +% \includegraphics[width=0.8\textwidth]{accuracy.png} +% \caption{Caption text.} +% \end{figure} +% +% Simple table (base LaTeX, no extra packages): +% \begin{table}[h] +% \centering +% \begin{tabular}{l r} +% \hline +% Metric & Value \\ +% \hline +% Accuracy & 0.789 \\ +% \hline +% \end{tabular} +% \caption{Caption text.} +% \end{table} +RESULTS_PLACEHOLDER + +\section{Discussion} +% Fill in: interpretation and limitations. +DISCUSSION_PLACEHOLDER + +\section*{References} +% Fill in: a plain list of sources. +\begin{itemize} + \item REFERENCE_PLACEHOLDER +\end{itemize} + +\end{document} diff --git a/pyproject.toml b/pyproject.toml index 033cc9b..83b60b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ asyncio_default_fixture_loop_scope = "function" markers = [ "llm: tests that require real LLM API calls (deselect with -m 'not llm')", "docker: tests that require a real container runtime (select with -m docker; set SANDBOX_REQUIRE_DOCKER=1 to fail instead of skip when absent)", + "latex: tests that require a host TeX engine (select with -m latex; set LATEX_REQUIRE=1 to fail instead of skip when absent)", ] [tool.ruff] diff --git a/src/agentic_cli/tools/__init__.py b/src/agentic_cli/tools/__init__.py index 2ce80af..b666ae9 100644 --- a/src/agentic_cli/tools/__init__.py +++ b/src/agentic_cli/tools/__init__.py @@ -50,6 +50,7 @@ fetch_arxiv_paper, ) from agentic_cli.tools.execution_tools import execute_python +from agentic_cli.tools.document import compile_document from agentic_cli.tools.interaction_tools import ask_clarification # Long-running job tools (generic observe-only management). Typed long-running @@ -126,6 +127,7 @@ "search_arxiv", "fetch_arxiv_paper", "execute_python", + "compile_document", "ask_clarification", # Long-running jobs (observe-only; typed starters are app-provided) "job_status", diff --git a/src/agentic_cli/tools/document/__init__.py b/src/agentic_cli/tools/document/__init__.py new file mode 100644 index 0000000..6ebd9c8 --- /dev/null +++ b/src/agentic_cli/tools/document/__init__.py @@ -0,0 +1,5 @@ +"""Document-generation tools (LaTeX → PDF).""" + +from agentic_cli.tools.document.compile import compile_document + +__all__ = ["compile_document"] diff --git a/src/agentic_cli/tools/document/compile.py b/src/agentic_cli/tools/document/compile.py new file mode 100644 index 0000000..6a86788 --- /dev/null +++ b/src/agentic_cli/tools/document/compile.py @@ -0,0 +1,205 @@ +"""Compile a LaTeX document to PDF with a host TeX engine. + +``compile_document`` is a narrow, permission-gated tool: it runs ``latexmk`` +(preferred) or ``pdflatex`` as a guarded subprocess — shell-escape is disabled +for the **pdflatex** path (``-no-shell-escape`` flag). For ``latexmk``, the +engine relies on its default restricted mode; note that ``latexmk`` also reads +``.latexmkrc`` (arbitrary Perl) from the build directory and home directory, so +callers with an untrusted ``.tex``/build directory should prefer ``pdflatex``. +OS-sandbox confinement for the general case is deferred. + +The tool runs with a wall-clock timeout and a scoped working dir, and returns a +structured result. It does NOT execute arbitrary code; a ``report_writer``-style +agent uses it to turn an authored ``.tex`` into a PDF. + +Provisioning is host-based: the engine must be on ``PATH`` (TeX Live / MacTeX). +If neither is found the tool returns a structured error with an install hint. + +The subprocess call and the engine lookup sit behind module-level seams +(``_run``, ``_which``) so the logic is unit-tested offline without a real TeX +install. +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import time +from pathlib import Path +from typing import Any + +from agentic_cli.tools.registry import ToolCategory, register_tool +from agentic_cli.workflow.permissions import Capability + +_ENGINES = ("latexmk", "pdflatex") +_LOG_TAIL_LINES = 40 + + +def _which(name: str) -> str | None: + """Locate an executable on PATH (seam for tests).""" + return shutil.which(name) + + +def _run( + argv: list[str], *, cwd: str, env: dict[str, str], timeout: float +) -> subprocess.CompletedProcess: + """Run a subprocess in its own process group so a timeout kills the whole + tree (latexmk + its pdflatex grandchild), not just the direct child. Seam + for tests. POSIX (macOS/Linux), which is what the framework targets.""" + proc = subprocess.Popen( + argv, cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True, + ) + try: + out, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + proc.communicate() # reap the killed group + raise + return subprocess.CompletedProcess(argv, proc.returncode, stdout=out, stderr=err) + + +def _detect_engine(engine: str | None) -> str | None: + """Return the engine to use, or None if unavailable.""" + if engine is not None: + return engine if (engine in _ENGINES and _which(engine)) else None + for candidate in _ENGINES: + if _which(candidate): + return candidate + return None + + +def _build_argv(engine: str, source: str) -> list[str]: + """Compiler argv — never enables shell-escape.""" + if engine == "latexmk": + return ["latexmk", "-pdf", "-interaction=nonstopmode", "-halt-on-error", source] + return [ + "pdflatex", "-no-shell-escape", "-interaction=nonstopmode", + "-halt-on-error", source, + ] + + +def _parse_errors(log_text: str) -> list[str]: + """Extract LaTeX error lines (those beginning with '!') from a log.""" + return [ln for ln in log_text.splitlines() if ln.startswith("!")] + + +@register_tool( + category=ToolCategory.EXECUTION, + capabilities=[Capability("document.compile", target_arg="source_path")], + description=( + "Compile a LaTeX source file to PDF using a host TeX engine (latexmk or " + "pdflatex). Shell-escape is disabled for pdflatex (-no-shell-escape); " + "latexmk uses its default restricted mode but also reads .latexmkrc from " + "the build/home directory. Returns the PDF path plus any compiler errors. " + "Requires TeX Live/MacTeX on PATH." + ), +) +def compile_document( + source_path: str, + output_pdf: str | None = None, + assets_dir: str | None = None, + engine: str | None = None, + timeout_s: int = 120, +) -> dict[str, Any]: + """Compile a LaTeX file to PDF (guarded subprocess; host TeX engine). + + Args: + source_path: Path to the .tex file to compile. + output_pdf: If set, the produced PDF is copied here (parents created); + build intermediates stay in the source's directory. + assets_dir: Directory prepended to TEXINPUTS so figures/resources resolve + by bare name (e.g. an artifacts dir). + engine: Force an engine ("latexmk"/"pdflatex"); default auto-detects + (latexmk preferred). + timeout_s: Wall-clock timeout; the process is killed on expiry. + + Returns: + dict with success, pdf_path, engine, log_tail, errors, duration_ms, and + (on setup/timeout failure) error. + """ + src = Path(source_path) + if not src.is_file(): + return { + "success": False, "error": f"Source not found: {source_path}", + "pdf_path": None, "engine": None, "log_tail": "", "errors": [], + "duration_ms": 0, + } + + chosen = _detect_engine(engine) + if chosen is None: + looked = engine or "/".join(_ENGINES) + return { + "success": False, + "error": ( + f"No LaTeX engine on PATH (looked for {looked}). " + "Install TeX Live or MacTeX." + ), + "pdf_path": None, "engine": None, "log_tail": "", "errors": [], + "duration_ms": 0, + } + + work_dir = src.parent + env = dict(os.environ) + if assets_dir: + prev = env.get("TEXINPUTS", "") + # Prepend assets_dir; trailing empty entry preserves the default path. + env["TEXINPUTS"] = f"{assets_dir}{os.pathsep}{prev}{os.pathsep}" + + argv = _build_argv(chosen, src.name) + start = time.monotonic() + try: + proc = _run(argv, cwd=str(work_dir), env=env, timeout=float(timeout_s)) + except subprocess.TimeoutExpired: + return { + "success": False, "error": f"Compilation timed out after {timeout_s}s", + "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], + "duration_ms": int((time.monotonic() - start) * 1000), + } + except OSError as exc: + return { + "success": False, "error": f"Failed to run {chosen}: {exc}", + "pdf_path": None, "engine": chosen, "log_tail": "", "errors": [], + "duration_ms": int((time.monotonic() - start) * 1000), + } + duration_ms = int((time.monotonic() - start) * 1000) + + log_path = work_dir / (src.stem + ".log") + try: + log_text = log_path.read_text(errors="replace") if log_path.is_file() else (proc.stdout or "") + except OSError: + log_text = proc.stdout or "" + log_tail = "\n".join(log_text.splitlines()[-_LOG_TAIL_LINES:]) + produced = work_dir / (src.stem + ".pdf") + success = proc.returncode == 0 and produced.is_file() + + if not success: + return { + "success": False, "pdf_path": None, "engine": chosen, + "log_tail": log_tail, "errors": _parse_errors(log_text), + "duration_ms": duration_ms, "error": None, + } + + final = produced + if output_pdf: + dest = Path(output_pdf) + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(produced, dest) + except OSError as exc: + return { + "success": False, + "error": f"Failed to deliver PDF to {output_pdf}: {exc}", + "pdf_path": str(produced), "engine": chosen, + "log_tail": log_tail, "errors": [], "duration_ms": duration_ms, + } + final = dest + + return { + "success": True, "pdf_path": str(final), "engine": chosen, + "log_tail": log_tail, "errors": [], "duration_ms": duration_ms, + "error": None, + } diff --git a/tests/examples/test_report_writer_skill.py b/tests/examples/test_report_writer_skill.py new file mode 100644 index 0000000..359f44e --- /dev/null +++ b/tests/examples/test_report_writer_skill.py @@ -0,0 +1,33 @@ +"""Resolution + asset tests for the demo report-writer skill (no LLM).""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("google.adk") + +from agentic_cli.tools.skills import SkillStore, make_skill_toolset + +_SKILLS_DIR = Path(__file__).resolve().parents[2] / "examples" / "research_demo" / "skills" + + +def test_skill_resolves(): + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) + assert len(skills) == 1 + assert skills[0].name == "report-writer" + assert skills[0].description # non-empty when-to-use + + +def test_template_asset_present(): + tpl = _SKILLS_DIR / "report-writer" / "assets" / "report_template.tex" + assert tpl.is_file() + body = tpl.read_text() + assert "\\documentclass" in body and "\\includegraphics" in body + + +def test_toolset_excludes_scripts(): + skills = SkillStore([str(_SKILLS_DIR)]).resolve(["report-writer"]) + names = {t.name for t in make_skill_toolset(skills)._tools} + assert "run_skill_script" not in names + assert {"list_skills", "load_skill", "load_skill_resource"} <= names diff --git a/tests/examples/test_research_demo_agents.py b/tests/examples/test_research_demo_agents.py index 534f4e4..abaa677 100644 --- a/tests/examples/test_research_demo_agents.py +++ b/tests/examples/test_research_demo_agents.py @@ -14,3 +14,24 @@ def test_data_analyst_wired(): assert "sandbox_execute" in tool_names coord = next(a for a in AGENT_CONFIGS if a.name == "research_coordinator") assert "data_analyst" in coord.sub_agents + + +def test_report_writer_wired(): + from research_demo.agents import AGENT_CONFIGS + + names = {a.name for a in AGENT_CONFIGS} + assert "report_writer" in names + rw = next(a for a in AGENT_CONFIGS if a.name == "report_writer") + tool_names = {t.__name__ for t in rw.tools} + assert "compile_document" in tool_names + assert "sandbox_execute" not in tool_names # no arbitrary code exec + assert rw.skills == ["report-writer"] + coord = next(a for a in AGENT_CONFIGS if a.name == "research_coordinator") + assert "report_writer" in coord.sub_agents + + +def test_report_writer_skills_dir_configured(): + from research_demo.settings import ResearchDemoSettings + + s = ResearchDemoSettings() + assert any(str(p).rstrip("/").endswith("skills") for p in s.skills_dirs) diff --git a/tests/tools/test_document_compile.py b/tests/tools/test_document_compile.py new file mode 100644 index 0000000..1a66369 --- /dev/null +++ b/tests/tools/test_document_compile.py @@ -0,0 +1,191 @@ +"""Offline tests for compile_document — subprocess and engine lookup faked.""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +from agentic_cli.tools.document import compile as mod +from agentic_cli.tools.document import compile_document + + +def _fake_engine(monkeypatch, name="latexmk"): + monkeypatch.setattr(mod, "_which", lambda n: f"/usr/bin/{n}" if n == name else None) + + +def test_no_engine_returns_structured_error(monkeypatch, tmp_path): + monkeypatch.setattr(mod, "_which", lambda n: None) + tex = tmp_path / "r.tex"; tex.write_text("x") + r = compile_document(str(tex)) + assert r["success"] is False + assert "No LaTeX engine" in r["error"] + assert r["engine"] is None + + +def test_missing_source_returns_error(tmp_path): + r = compile_document(str(tmp_path / "nope.tex")) + assert r["success"] is False and "not found" in r["error"] + + +def test_success_places_pdf_and_keeps_intermediates(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex" + tex.write_text("\\documentclass{article}\\begin{document}hi\\end{document}") + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.pdf").write_bytes(b"%PDF-1.5 fake") + (Path(cwd) / "r.log").write_text("output written on r.pdf") + (Path(cwd) / "r.aux").write_text("\\relax") + return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + out = tmp_path / "deliver" / "report.pdf" + r = compile_document(str(tex), output_pdf=str(out), assets_dir=str(tmp_path / "assets")) + assert r["success"] is True + assert r["pdf_path"] == str(out) + assert out.is_file() # PDF promoted to delivery dir + assert (tmp_path / "r.aux").is_file() # intermediates stay in build dir + assert not (out.parent / "r.aux").exists() # not beside the delivered PDF + + +def test_failure_parses_errors(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("bad") + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.log").write_text("! Undefined control sequence.\nl.5 \\badcmd\n") + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex)) + assert r["success"] is False + assert any("Undefined control sequence" in e for e in r["errors"]) + assert r["pdf_path"] is None + + +def test_argv_never_enables_shell_escape(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + captured = {} + + def fake_run(argv, *, cwd, env, timeout): + captured["argv"] = argv; captured["env"] = env + (Path(cwd) / "r.pdf").write_bytes(b"%PDF") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + compile_document(str(tex), assets_dir="/tmp/assets") + assert "-shell-escape" not in captured["argv"] + assert "-halt-on-error" in captured["argv"] + assert captured["argv"][0] == "latexmk" + assert "/tmp/assets" in captured["env"]["TEXINPUTS"] + + +def test_pdflatex_uses_no_shell_escape_flag(monkeypatch, tmp_path): + _fake_engine(monkeypatch, name="pdflatex") + tex = tmp_path / "r.tex"; tex.write_text("x") + captured = {} + + def fake_run(argv, *, cwd, env, timeout): + captured["argv"] = argv + (Path(cwd) / "r.pdf").write_bytes(b"%PDF") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + compile_document(str(tex)) + assert captured["argv"][0] == "pdflatex" + assert "-no-shell-escape" in captured["argv"] + + +def test_timeout_returns_structured_failure(monkeypatch, tmp_path): + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + + def fake_run(argv, *, cwd, env, timeout): + raise subprocess.TimeoutExpired(argv, timeout) + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex), timeout_s=1) + assert r["success"] is False and "timed out" in r["error"] + + +# --- Fix wave 1 tests --- + +def test_run_raises_file_not_found_returns_structured_error(monkeypatch, tmp_path): + """Finding 1: _run raising FileNotFoundError must not propagate; must return failure dict.""" + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + + def fake_run(argv, *, cwd, env, timeout): + raise FileNotFoundError("latexmk: not found") + + monkeypatch.setattr(mod, "_run", fake_run) + r = compile_document(str(tex)) + assert r["success"] is False + assert r["error"] is not None and len(r["error"]) > 0 + assert r["engine"] == "latexmk" + assert r["pdf_path"] is None + assert "duration_ms" in r + + +def test_pdf_copy_oserror_returns_structured_error(monkeypatch, tmp_path): + """Finding 2: OSError during PDF delivery must not propagate; must return failure dict.""" + _fake_engine(monkeypatch) + tex = tmp_path / "r.tex"; tex.write_text("x") + out = tmp_path / "deliver" / "report.pdf" + + def fake_run(argv, *, cwd, env, timeout): + (Path(cwd) / "r.pdf").write_bytes(b"%PDF-1.5 fake") + (Path(cwd) / "r.log").write_text("output written on r.pdf") + return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="") + + monkeypatch.setattr(mod, "_run", fake_run) + monkeypatch.setattr(mod.shutil, "copy2", lambda src, dst: (_ for _ in ()).throw(OSError("disk full"))) + + r = compile_document(str(tex), output_pdf=str(out)) + assert r["success"] is False + assert "deliver" in r["error"].lower() or str(out) in r["error"] + assert r["pdf_path"] is not None # PDF still exists in build dir + assert "duration_ms" in r + + +def test_forced_unsupported_engine_rejected(monkeypatch, tmp_path): + """Finding 3: forcing engine='xelatex' (not in _ENGINES) must be rejected → No LaTeX engine error.""" + monkeypatch.setattr(mod, "_which", lambda n: f"/usr/bin/{n}" if n == "xelatex" else None) + tex = tmp_path / "r.tex"; tex.write_text("x") + r = compile_document(str(tex), engine="xelatex") + assert r["success"] is False + assert "No LaTeX engine" in r["error"] + + +# --- Fix wave — final review (I1/I2/M1/M2/M3) --- + +def test_run_group_kill_on_timeout(monkeypatch): + """I1: _run must start a new session and kill the process group on timeout.""" + import subprocess as _subprocess + + popen_kwargs: dict = {} + killpg_calls: list = [] + + class FakePopen: + pid = 42 + + def __init__(self, argv, **kwargs): + popen_kwargs.update(kwargs) + + def communicate(self, timeout=None): + if timeout is not None: + raise _subprocess.TimeoutExpired([], timeout) + # reap call after kill + return ("", "") + + monkeypatch.setattr(mod.subprocess, "Popen", FakePopen) + monkeypatch.setattr(mod.os, "getpgid", lambda pid: pid) + monkeypatch.setattr(mod.os, "killpg", lambda pgid, sig: killpg_calls.append((pgid, sig))) + + try: + mod._run(["latexmk"], cwd="/tmp", env={}, timeout=1.0) + except _subprocess.TimeoutExpired: + pass # expected + + assert popen_kwargs.get("start_new_session") is True, "Popen must use start_new_session=True" + assert len(killpg_calls) >= 1, "os.killpg must be called on timeout" diff --git a/tests/tools/test_document_compile_latex.py b/tests/tools/test_document_compile_latex.py new file mode 100644 index 0000000..8fec1c2 --- /dev/null +++ b/tests/tools/test_document_compile_latex.py @@ -0,0 +1,35 @@ +"""Real-engine compile — skipped unless a TeX engine is installed. + +Set LATEX_REQUIRE=1 to fail (not skip) when no engine is present. +""" +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + +from agentic_cli.tools import compile_document + +pytestmark = pytest.mark.latex + +_HAS_ENGINE = shutil.which("latexmk") or shutil.which("pdflatex") +if not _HAS_ENGINE and os.environ.get("LATEX_REQUIRE") != "1": + pytest.skip("no LaTeX engine (latexmk/pdflatex) on PATH", allow_module_level=True) + + +def test_compiles_minimal_document(tmp_path): + build = tmp_path / "build"; build.mkdir() + tex = build / "r.tex" + tex.write_text( + "\\documentclass{article}\n\\begin{document}\n" + "Hello \\textbf{world}.\n\\end{document}\n" + ) + out = tmp_path / "deliver" / "report.pdf" + r = compile_document(str(tex), output_pdf=str(out)) + assert r["success"] is True, r + assert Path(r["pdf_path"]).is_file() and Path(r["pdf_path"]).stat().st_size > 0 + assert out.is_file() + assert (build / "r.log").is_file() # intermediates in build dir + assert not (out.parent / "r.log").exists() # not beside delivered PDF