diff --git a/agentdescent/cli.py b/agentdescent/cli.py index 378cea62..6684ff9c 100644 --- a/agentdescent/cli.py +++ b/agentdescent/cli.py @@ -2125,6 +2125,7 @@ def cmd_run(a: argparse.Namespace) -> int: def status_payload(run_id: Optional[str], *, store: Optional[str] = None, recent_rounds: int = 3) -> Any: if run_id: + run_id = resolve_run_id(run_id, store=store) rd = runstore.get(run_id, store=store) st = rd.status().to_dict() st["recent_rounds"] = rd.rounds()[-recent_rounds:] @@ -2387,6 +2388,7 @@ def _partial_show(rd: "runstore.RunDir", st: Any) -> Dict[str, Any]: def show_payload(run_id: str, *, store: Optional[str] = None, diff: bool = True, max_chars: int = 40_000) -> Dict[str, Any]: + run_id = resolve_run_id(run_id, store=store) or run_id rd = runstore.get(run_id, store=store) st = rd.status() result = rd.result() @@ -2551,6 +2553,7 @@ def resolve_apply_dest(to: Optional[str], install_to: Optional[str]) -> Optional def apply_payload(run_id: str, *, to: Optional[str] = None, store: Optional[str] = None, dry_run: bool = False, backup: bool = True) -> Dict[str, Any]: + run_id = resolve_run_id(run_id, store=store) or run_id rd = runstore.get(run_id, store=store) result = rd.result() if result is None: @@ -5426,10 +5429,33 @@ def _accept_json_after_verb(sub: "argparse._SubParsersAction") -> None: help="machine-readable output") -#: Words that stand for "the newest run in the store" wherever a run id goes (#892). +#: Words that stand for "the newest run in the store" wherever a run id goes (#892, #896). LATEST_RUN_ALIASES = ("latest", "last") +def resolve_run_id(run_id: Optional[str], *, store: Optional[str] = None + ) -> Optional[str]: + """Map ``latest`` / ``last`` to the newest run id. + + A real run directory named ``latest`` or ``last`` still wins. An empty + store raises :class:`~agentdescent.runstore.RunStoreError` with the same + wording the CLI uses (#892), so MCP tools and payload helpers match + ``agentdescent show latest`` instead of looking up a folder named + ``latest`` (#896). Any other value, including ``None``, is returned + unchanged. + """ + if not isinstance(run_id, str) or run_id.lower() not in LATEST_RUN_ALIASES: + return run_id + if os.path.isdir(os.path.join(runstore.store_path(store), run_id)): + return run_id + runs = runstore.list_runs(store=store) + if not runs: + raise runstore.RunStoreError( + f"no runs under {runstore.store_path(store)} yet, so {run_id!r} has nothing " + "to point at; start one with `agentdescent demo` or `agentdescent evolve`") + return runs[0].run_id + + def _resolve_latest_run(a: argparse.Namespace) -> None: """Swap ``latest`` / ``last`` for the newest run id on run-id commands. @@ -5440,17 +5466,9 @@ def _resolve_latest_run(a: argparse.Namespace) -> None: cmd_cancel, cmd_resume, cmd_diagnose): return rid = getattr(a, "run_id", None) - if not isinstance(rid, str) or rid.lower() not in LATEST_RUN_ALIASES: - return - store = getattr(a, "store", None) - if os.path.isdir(os.path.join(runstore.store_path(store), rid)): - return - runs = runstore.list_runs(store=store) - if not runs: - raise runstore.RunStoreError( - f"no runs under {runstore.store_path(store)} yet, so {rid!r} has nothing " - "to point at; start one with `agentdescent demo` or `agentdescent evolve`") - a.run_id = runs[0].run_id + resolved = resolve_run_id(rid, store=getattr(a, "store", None)) + if resolved is not None: + a.run_id = resolved def main(argv: Optional[Sequence[str]] = None) -> int: diff --git a/agentdescent/diagnose.py b/agentdescent/diagnose.py index fa0f271d..29f6df6c 100644 --- a/agentdescent/diagnose.py +++ b/agentdescent/diagnose.py @@ -183,6 +183,8 @@ def diagnose_payload(run_id: str, *, store: Optional[str] = None) -> DiagnosisRe from .cli import show_payload payload = show_payload(run_id, store=store, diff=False) + # show_payload maps latest/last (#896); report the resolved id. + run_id = str(payload.get("run_id") or run_id) state = str(payload.get("state") or "") if state == "cancelled": return DiagnosisReport( diff --git a/agentdescent/mcp.py b/agentdescent/mcp.py index 1ed85d9c..25994fae 100644 --- a/agentdescent/mcp.py +++ b/agentdescent/mcp.py @@ -37,7 +37,7 @@ from . import __version__, runstore from .cli import (NESTED_ENV, apply_from_payload, apply_payload, doctor_report, plan_payload, require_cli_on_path, require_resume_host_on_path, - show_payload, status_payload) + resolve_run_id, show_payload, status_payload) from .evolvespec import (EvolveSpec, SpecError, budget_round_overshoot_warning, compose, estimate) @@ -147,24 +147,27 @@ def advanced_from_env(environ: Optional[Dict[str, str]] = None) -> bool: "Progress of one run (round, best held-out reward, calls, dollars if priced, " "state, the last few rounds, and `audit_store` when the run has one) or, with no " "run_id, {store, runs} where `runs` is " - "every run newest first and is empty when there are none. Cheap; safe to poll. " + "every run newest first and is empty when there are none. `run_id` may be " + "`latest` or `last` for the newest run (same as the CLI). Cheap; safe to poll. " "Summarise round-to-round deltas for the user rather than pasting JSON."), "show": ( "The evolved artifact when a run is done: for a directory, the list of files, a " "unified diff against the original, and the plan `apply` would execute (files " "written, extra files, backups); for text, the evolved instruction. Also the " - "outcomes() histogram that says why proposals were committed or refused. Explain " - "what changed and why; do not paste the whole tree."), + "outcomes() histogram that says why proposals were committed or refused. Pass the " + "run_id from start, or `latest`/`last` for the newest run (same as the CLI). " + "Explain what changed and why; do not paste the whole tree."), "diagnose": ( "Explain why a run ended where it did: solved, seed-already-solved, " "partial-improvement, running, " "cancelled, run-failed, reflector-error, scoring-mismatch, agent-failures, " "gate-rejected, no-improvement or unknown, with bounded evidence and the next " - "step. Call this after `show` when the run learned little, instead of guessing " + "step. `run_id` may be `latest` or `last` for the newest run (same as the CLI). " + "Call this after `show` when the run learned little, instead of guessing " "from the outcome histogram."), "apply": ( "DESTRUCTIVE: write an evolved artifact back over a target. Pass `run_id` to " - "install a finished run's result (optional `to` / `install_to` overrides the run's target; `install_to` is the host-skills-root form for skill_library), OR " + "install a finished run's result (`latest`/`last` = newest run, same as the CLI; optional `to` / `install_to` overrides the run's target; `install_to` is the host-skills-root form for skill_library), OR " "pass `from` + `to` to install an on-disk artifact such as a campaign " "`final_target` -- same rules as CLI `apply --from` (from requires to; mutually " "exclusive with run_id; symlinks refused). Backs the original up first unless " @@ -173,11 +176,13 @@ def advanced_from_env(environ: Optional[Dict[str, str]] = None) -> bool: "already matched, so nothing was written and no backup exists -- tell the user " "nothing changed. Otherwise tell them the backup path afterwards."), "cancel": ( - "Stop a running evolution and every worker agent it started. The run keeps its " + "Stop a running evolution and every worker agent it started. `run_id` may be " + "`latest` or `last` for the newest run (same as the CLI). The run keeps its " "ledger and can be resumed."), "resume": ( "Continue a stopped, failed or cancelled run on its existing ledger (the engine " - "picks up where it left off). Returns the new status."), + "picks up where it left off). `run_id` may be `latest` or `last` for the newest " + "run (same as the CLI). Returns the new status."), "audit_status": ( "Read a sparse-audit JSONL: how biased the run's cheap verifier is against ground " "truth (`delta_hat`), how *scattered* that error is (`resid_sd` -- the number the " @@ -680,6 +685,7 @@ def apply(self, run_id: Optional[str] = None, to: Optional[str] = None, def cancel(self, run_id: str) -> Dict[str, Any]: try: + run_id = resolve_run_id(run_id, store=self.store) or run_id return runstore.cancel(run_id, store=self.store).to_dict() except runstore.RunStoreError as e: return {"error": str(e)} @@ -689,6 +695,7 @@ def resume(self, run_id: str, budget_usd: Optional[float] = None, try: # A resumed run gets the *current* bridge: the session that started # it is likely gone, and its address with it. + run_id = resolve_run_id(run_id, store=self.store) or run_id runstore.require_budget_pair(budget_usd, usd_per_call) # Same host gate as CLI resume: an absent plugin host must not # relaunch and report running (#370). diff --git a/changelog.d/896-mcp-latest-run-alias.md b/changelog.d/896-mcp-latest-run-alias.md new file mode 100644 index 00000000..f0d652d3 --- /dev/null +++ b/changelog.d/896-mcp-latest-run-alias.md @@ -0,0 +1,2 @@ +Fixed +`show_payload` / `status_payload` / `apply_payload` and the MCP `show` / `status` / `apply` / `diagnose` / `cancel` / `resume` tools accept `latest` (or `last`) the same way the CLI does, so a host that asks to show the newest run no longer gets `no run 'latest'` (#896). diff --git a/tests/test_latest_run_alias.py b/tests/test_latest_run_alias.py index ee1fc704..9e8ed677 100644 --- a/tests/test_latest_run_alias.py +++ b/tests/test_latest_run_alias.py @@ -1,8 +1,10 @@ -"""`latest` / `last` stand for the newest run id on run-id commands (#892).""" +"""`latest` / `last` stand for the newest run id on run-id commands (#892, #896).""" import json import os +import pytest + from agentdescent import cli, runstore @@ -42,3 +44,48 @@ def test_other_ids_untouched(tmp_path, capsys): _mk(store, "20260101-000000-aaaaaa") assert cli.main(["--store", store, "status", "nope"]) == 2 assert "no run 'nope'" in capsys.readouterr().err + + +def test_payload_helpers_resolve_latest(tmp_path): + store = str(tmp_path / "runs") + _mk(store, "20260101-000000-aaaaaa") + _mk(store, "20260202-000000-bbbbbb") + shown = cli.show_payload("latest", store=store) + assert shown["run_id"] == "20260202-000000-bbbbbb" + st = cli.status_payload("LAST", store=store) + assert st["run_id"] == "20260202-000000-bbbbbb" + with pytest.raises(runstore.RunStoreError, match="20260202-000000-bbbbbb"): + cli.apply_payload("latest", store=store) + + +def test_mcp_tools_resolve_latest(tmp_path): + from agentdescent.mcp import Tools + + store = str(tmp_path / "runs") + _mk(store, "20260101-000000-aaaaaa") + _mk(store, "20260202-000000-bbbbbb") + t = Tools(store) + assert t.show("latest")["run_id"] == "20260202-000000-bbbbbb" + assert t.status("last")["run_id"] == "20260202-000000-bbbbbb" + assert t.diagnose("LATEST")["run_id"] == "20260202-000000-bbbbbb" + err = t.apply("latest") + assert "error" in err + assert "20260202-000000-bbbbbb" in err["error"] + assert "no run 'latest'" not in err["error"] + cancelled = t.cancel("latest") + assert cancelled.get("run_id") == "20260202-000000-bbbbbb" or ( + "error" in cancelled and "no run 'latest'" not in cancelled["error"]) + + +def test_mcp_empty_store_latest_is_clear(tmp_path): + from agentdescent.mcp import Tools + + store = str(tmp_path / "empty") + t = Tools(store) + for fn in (t.show, t.diagnose, t.cancel, t.resume, t.apply): + out = fn("latest") + assert "error" in out + assert "no runs under" in out["error"] and "agentdescent demo" in out["error"] + out = t.status("latest") + assert "error" in out + assert "no runs under" in out["error"]