Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ uses Semantic Versioning for public releases.

## Unreleased

### Changed
- `recommended_next_steps` no longer interpolates the caller's own file paths
into every suggestion. A four-step list used to repeat the same absolute
path four times; on an MPAS QU480 mesh those echoed paths alone were 28% of
an `inspect_mesh` result and 36% of an `inspect_variable` result, more bytes
than every computed number in either reply. Steps now reference a
caller-supplied value by parameter name (`plot_mesh(grid_path)`), spell out
only values the server discovered (`plot_variable(grid_path, data_path,
"temperature")`), and bracket what is still missing (`<data_path>`). Results
shrank 18-34% and the computed answer went from 25-49% of a payload to
39-61% (#83).

### Fixed
- Preserve the original remote worker exception when synchronous MCP tools run
inside an event loop instead of masking it with a nested `asyncio.run` error.
Expand Down
48 changes: 48 additions & 0 deletions src/uxarray_mcp/next_steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Compact follow-up suggestions for tool results (#83).

``recommended_next_steps`` (#30) exists so an agent can chain a workflow
without already knowing the tool vocabulary. That is worth keeping. What was
not worth keeping is *how* it was spelled: every step interpolated the
caller's own absolute file paths back into the string, so a four-step list
repeated the same path four times.

Measured on an MPAS QU480 mesh before this change, the echoed paths alone
were 28% of an ``inspect_mesh`` result and 36% of an ``inspect_variable``
result -- more bytes than every computed number in either reply. The caller
supplied those paths in the request it just made, so sending them back
teaches it nothing, and results are re-sent on every later turn.

The rule is one line: echo a value only when the caller did not already
have it. That gives three ways to render an argument.

``"grid_path"``
A value the caller passed in, referenced by parameter name. Bare rather
than quoted, because quoting would read as a literal filename.
``literal(name)``
A value the server discovered by opening the file, such as a variable
name. Genuinely new, so it is spelled out in full.
``needed("data_path")``
A value the caller has not supplied yet and must provide to make the
call, rendered ``<data_path>``.

Tool names are always spelled out: naming the next tool is the whole point.
"""

from __future__ import annotations


def literal(value: str | float) -> str:
"""Quote a value the server discovered and the caller may not know."""
return f'"{value}"' if isinstance(value, str) else str(value)


def needed(name: str) -> str:
"""Mark an argument the caller must still supply."""
return f"<{name}>"


def call(tool: str, *args: str, note: str | None = None, **kwargs: str) -> str:
"""Render one suggested call as ``tool(a, b, k=v) - note``."""
rendered = list(args) + [f"{key}={value}" for key, value in kwargs.items()]
step = f"{tool}({', '.join(rendered)})"
return f"{step} - {note}" if note else step
30 changes: 21 additions & 9 deletions src/uxarray_mcp/tools/advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from uxarray_mcp.domain.mesh import load_dataset, load_grid
from uxarray_mcp.domain.remap_coverage import compute_target_coverage
from uxarray_mcp.next_steps import call, needed
from uxarray_mcp.provenance import attach_provenance
from uxarray_mcp.state import (
OperationTracker,
Expand Down Expand Up @@ -226,13 +227,15 @@ def subset_bbox(
"result_handle": result_handle,
}
next_steps = [
f'plot_mesh(grid_path="{resolved_grid}")',
f'export_to_netcdf("<output.nc>", result_handle="{result_handle}")',
call("plot_mesh", grid_path="grid_path"),
# result_handle is echoed as its own key in this same result, so the
# caller can read it there rather than have it repeated in prose.
call("export_to_netcdf", needed("output.nc"), result_handle="result_handle"),
]
if resolved_data is not None:
next_steps.insert(
0,
f'plot_variable("{resolved_grid}", "{resolved_data}", "<variable_name>")',
call("plot_variable", "grid_path", "data_path", needed("variable_name")),
)
result["recommended_next_steps"] = next_steps
tracker.succeed("Bounding-box subset complete.")
Expand Down Expand Up @@ -330,13 +333,15 @@ def subset_polygon(
"result_handle": result_handle,
}
next_steps = [
f'plot_mesh(grid_path="{resolved_grid}")',
f'export_to_netcdf("<output.nc>", result_handle="{result_handle}")',
call("plot_mesh", grid_path="grid_path"),
# result_handle is echoed as its own key in this same result, so the
# caller can read it there rather than have it repeated in prose.
call("export_to_netcdf", needed("output.nc"), result_handle="result_handle"),
]
if resolved_data is not None:
next_steps.insert(
0,
f'plot_variable("{resolved_grid}", "{resolved_data}", "<variable_name>")',
call("plot_variable", "grid_path", "data_path", needed("variable_name")),
)
result["recommended_next_steps"] = next_steps
result = attach_provenance(
Expand Down Expand Up @@ -426,13 +431,20 @@ def extract_cross_section(
"result_handle": result_handle,
}
next_steps = [
f'plot_mesh(grid_path="{resolved_grid}")',
f'export_to_netcdf("<output.nc>", result_handle="{result_handle}")',
call("plot_mesh", grid_path="grid_path"),
# result_handle is echoed as its own key in this same result, so the
# caller can read it there rather than have it repeated in prose.
call("export_to_netcdf", needed("output.nc"), result_handle="result_handle"),
]
if resolved_data is not None:
next_steps.insert(
0,
f'calculate_zonal_mean("{resolved_grid}", "{resolved_data}", "<variable_name>")',
call(
"calculate_zonal_mean",
"grid_path",
"data_path",
needed("variable_name"),
),
)
result["recommended_next_steps"] = next_steps
result = attach_provenance(
Expand Down
64 changes: 44 additions & 20 deletions src/uxarray_mcp/tools/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
load_dataset,
load_grid,
)
from uxarray_mcp.next_steps import call, literal, needed
from uxarray_mcp.provenance import attach_provenance


Expand Down Expand Up @@ -91,10 +92,10 @@ def _inspect_mesh_local(file_path: str) -> Dict[str, Any]:
"n_max_face_nodes": int(grid.n_max_face_nodes),
"file_size_mb": round(file_size_mb, 2),
"recommended_next_steps": [
f'calculate_area("{file_path}")',
f'plot_mesh("{file_path}")',
f'inspect_variable("{file_path}", "<data_path>")',
f'validate_dataset("{file_path}", "<data_path>")',
call("calculate_area", "grid_path"),
call("plot_mesh", "grid_path"),
call("inspect_variable", "grid_path", needed("data_path")),
call("validate_dataset", "grid_path", needed("data_path")),
],
},
tool="inspect_mesh",
Expand Down Expand Up @@ -163,15 +164,23 @@ def _inspect_variable_local(
next_steps = []
if face_vars:
v0 = face_vars[0]
# v0 is discovered by opening the file, so it is spelled out; the
# paths came from the caller and stay as parameter names.
next_steps = [
f'plot_variable("{grid_path}", "{data_path}", "{v0}")',
f'calculate_zonal_mean("{grid_path}", "{data_path}", "{v0}")',
f'validate_dataset("{grid_path}", "{data_path}")',
f'subset_bbox([-60,60], [-30,30], grid_path="{grid_path}", '
f'data_path="{data_path}", variable_name="{v0}")',
call("plot_variable", "grid_path", "data_path", literal(v0)),
call("calculate_zonal_mean", "grid_path", "data_path", literal(v0)),
call("validate_dataset", "grid_path", "data_path"),
call(
"subset_bbox",
"[-60, 60]",
"[-30, 30]",
grid_path="grid_path",
data_path="data_path",
variable_name=literal(v0),
),
]
else:
next_steps = [f'validate_dataset("{grid_path}", "{data_path}")']
next_steps = [call("validate_dataset", "grid_path", "data_path")]
info["recommended_next_steps"] = next_steps
return attach_provenance(
info,
Expand Down Expand Up @@ -234,9 +243,14 @@ def _calculate_area_local(file_path: str) -> Dict[str, Any]:
raise RuntimeError(f"Failed to calculate face areas: {str(e)}")

result["recommended_next_steps"] = [
f'plot_mesh("{file_path}")',
f'inspect_variable("{file_path}", "<data_path>")',
f'calculate_zonal_mean("{file_path}", "<data_path>", "<variable_name>")',
call("plot_mesh", "grid_path"),
call("inspect_variable", "grid_path", needed("data_path")),
call(
"calculate_zonal_mean",
"grid_path",
needed("data_path"),
needed("variable_name"),
),
]
return attach_provenance(
result, tool="calculate_area", inputs={"file_path": file_path}
Expand Down Expand Up @@ -304,10 +318,15 @@ def _calculate_zonal_mean_local(
raise RuntimeError(f"Failed to compute zonal mean: {str(e)}")

result["recommended_next_steps"] = [
f'plot_zonal_mean("{grid_path}", "{data_path}", "{variable_name}")',
f'plot_variable("{grid_path}", "{data_path}", "{variable_name}")',
f'extract_cross_section(latitude=0.0, grid_path="{grid_path}", '
f'data_path="{data_path}", variable_name="{variable_name}")',
call("plot_zonal_mean", "grid_path", "data_path", "variable_name"),
call("plot_variable", "grid_path", "data_path", "variable_name"),
call(
"extract_cross_section",
"grid_path",
"data_path",
"variable_name",
latitude="0.0",
),
]
return attach_provenance(
result,
Expand Down Expand Up @@ -636,9 +655,14 @@ def validate_dataset(grid_path: str, data_path: str) -> Dict[str, Any]:

if overall_passed:
result["recommended_next_steps"] = [
f'inspect_variable("{grid_path}", "{data_path}")',
f'calculate_zonal_mean("{grid_path}", "{data_path}", "<variable_name>")',
f'plot_variable("{grid_path}", "{data_path}", "<variable_name>")',
call("inspect_variable", "grid_path", "data_path"),
call(
"calculate_zonal_mean",
"grid_path",
"data_path",
needed("variable_name"),
),
call("plot_variable", "grid_path", "data_path", needed("variable_name")),
]
else:
result["recommended_next_steps"] = [
Expand Down
46 changes: 36 additions & 10 deletions src/uxarray_mcp/tools/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
from typing import Any, Optional

from uxarray_mcp.next_steps import call, literal, needed
from uxarray_mcp.provenance import attach_provenance


Expand Down Expand Up @@ -280,31 +281,56 @@ def analyze_dataset(
next_steps: list[str] = []
if resolved_data is None:
next_steps.append(
f'inspect_variable("{resolved_grid}", "<data_path>") '
"— rerun with a data file to unlock variable analysis"
call(
"inspect_variable",
"grid_path",
needed("data_path"),
note="rerun with a data file to unlock variable analysis",
)
)
if validation is not None and validation.get("passed") is False:
next_steps.append(
"Validation failed; review the per-variable warnings before "
"trusting downstream results."
)
if selected_variable and resolved_data is not None:
# selected_variable was chosen by the server, so it is spelled out.
next_steps.append(
f'plot_zonal_mean("{resolved_grid}", "{resolved_data}", '
f'"{selected_variable}") — render the zonal profile'
call(
"plot_zonal_mean",
"grid_path",
"data_path",
literal(selected_variable),
note="render the zonal profile",
)
)
next_steps.append(
f'extract_cross_section(latitude=0.0, grid_path="{resolved_grid}", '
f'data_path="{resolved_data}", variable_name="{selected_variable}")'
call(
"extract_cross_section",
latitude="0.0",
grid_path="grid_path",
data_path="data_path",
variable_name=literal(selected_variable),
)
)
next_steps.append(
f"subset_bbox(lon_bounds=[-180, 180], lat_bounds=[-90, 90], "
f'grid_path="{resolved_grid}", data_path="{resolved_data}", '
f'variable_name="{selected_variable}") — focus on a region'
call(
"subset_bbox",
lon_bounds="[-180, 180]",
lat_bounds="[-90, 90]",
grid_path="grid_path",
data_path="data_path",
variable_name=literal(selected_variable),
note="focus on a region",
)
)
if not next_steps:
next_steps.append(
f'plot_mesh(grid_path="{resolved_grid}") — visualize the mesh wireframe'
call(
"plot_mesh",
grid_path="grid_path",
note="visualize the mesh wireframe",
)
)

result: dict[str, Any] = {
Expand Down
Loading
Loading