Skip to content

Commit bd5d024

Browse files
committed
Mcp(feat[filter]): Say what a filter may name
why: A real agent asked for the pane in the window named 'build', guessed a plain field map for the filter, and was told only "'schema' is missing or not a string" — which says what broke and not what to send. It spent a call finding out. The pane model has four fields and none of them is a window's name, and nothing said so. what: - List the pane model's fields in tmux_list_panes's own description, and say where to look for anything else - Answer an unreadable filter with the shape to copy and the fields it may name, rather than the parser's own words - Add FilterModel.fieldNames and relationNames, since what a model may name was knowable only from inside the class
1 parent a0fb8e0 commit bd5d024

5 files changed

Lines changed: 93 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ exact version rather than a range.
1212

1313
## Unreleased
1414

15+
### Added
16+
17+
- **`FilterModel.fieldNames` and `relationNames` say what a document may name.**
18+
The useful thing to tell a caller whose field was not recognised is which ones
19+
exist, and nothing outside the class could find that out. `libtmux-mcp` now puts
20+
the pane model's fields in `tmux_list_panes`'s own description and repeats them
21+
when a filter will not read — measured against a real agent, which guessed a
22+
plain field map and spent a call discovering the shape.
23+
1524
## 0.0.1-alpha.5 — 2026-08-16
1625

1726
### Added

libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterModel.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import io.github.libtmux.query.Fields;
55
import java.util.Collections;
66
import java.util.LinkedHashMap;
7+
import java.util.LinkedHashSet;
78
import java.util.List;
89
import java.util.Map;
910
import java.util.Optional;
11+
import java.util.Set;
1012
import java.util.function.Function;
1113
import org.jspecify.annotations.Nullable;
1214

@@ -44,6 +46,24 @@ public String id() {
4446
return id;
4547
}
4648

49+
/**
50+
* Every field a document may compare on, in the order the model declared them.
51+
*
52+
* <p>Public because the useful thing to say about a field nobody recognises is which ones exist.
53+
* A caller writing a document by hand — or a model being told why its last one was refused —
54+
* cannot otherwise find out without reading this file.
55+
*/
56+
public Set<String> fieldNames() {
57+
return fields.keySet();
58+
}
59+
60+
/** Every relation a document may navigate, in the order the model declared them. */
61+
public Set<String> relationNames() {
62+
Set<String> names = new LinkedHashSet<>(toOne.keySet());
63+
names.addAll(toMany.keySet());
64+
return Collections.unmodifiableSet(names);
65+
}
66+
4767
FieldRef<T, ?> field(String fieldId) {
4868
FieldRef<T, ?> field = fields.get(fieldId);
4969
if (field == null) {

libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import static io.github.libtmux.mcp.Argument.strings;
1010

1111
import io.github.libtmux.jackson.FilterJson;
12+
import io.github.libtmux.jackson.LibTmuxModels;
1213
import java.util.ArrayList;
1314
import java.util.LinkedHashMap;
1415
import java.util.List;
@@ -96,7 +97,12 @@ private static void discovery(List<ToolSpec> tools) {
9697
"filter",
9798
"object",
9899
"A " + FilterJson.SCHEMA + " document over the pane model, for example " + EXAMPLE_FILTER
99-
+ ". Field names are tmux's own format names. Omit it to list every pane.",
100+
+ ". Field names are tmux's own format names, and these are the only ones a pane "
101+
+ "document may compare: "
102+
+ String.join(", ", LibTmuxModels.pane().fieldNames())
103+
+ ". Anything else — a window's name, a pane's path — is in the answer rather "
104+
+ "than the filter, so list the panes and choose from what comes back. Omit it "
105+
+ "to list every pane.",
100106
false,
101107
null)),
102108
Listings::panes));

libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,7 @@ static Panes panes(Call call) {
129129
String note = null;
130130
Object filter = call.arguments().get("filter");
131131
if (filter != null) {
132-
FilterExpr<Pane> expression = FilterJson.read(JSON.valueToTree(filter), LibTmuxModels.pane());
133-
List<Pane> narrowed = panes.stream().filter(expression).toList();
132+
List<Pane> narrowed = panes.stream().filter(paneFilter(filter)).toList();
134133
note = narrowed.isEmpty() && !panes.isEmpty()
135134
? "The filter matched none of the " + panes.size() + " panes on this server. "
136135
+ "Call again without 'filter' to see them all."
@@ -140,6 +139,26 @@ static Panes panes(Call call) {
140139
return new Panes(panes.size(), describe(panes, caller), note);
141140
}
142141

142+
/**
143+
* Reads a filter document, and says what one looks like when it will not read.
144+
*
145+
* <p>What the parser knows is that a key was missing or a field unrecognised. What a caller
146+
* needs is the shape to send and the names it may use — neither of which the parser has any
147+
* business knowing, and both of which are free here.
148+
*/
149+
private static FilterExpr<Pane> paneFilter(Object filter) {
150+
try {
151+
return FilterJson.read(JSON.valueToTree(filter), LibTmuxModels.pane());
152+
} catch (RuntimeException e) {
153+
throw new IllegalArgumentException("that filter is not a " + FilterJson.SCHEMA + " document: "
154+
+ e.getMessage() + ". One looks like " + Catalog.EXAMPLE_FILTER
155+
+ " and may compare these fields only: "
156+
+ String.join(", ", LibTmuxModels.pane().fieldNames())
157+
+ ". To narrow by anything else — a window's name, a pane's path — list the panes "
158+
+ "and choose from what comes back.");
159+
}
160+
}
161+
143162
static List<PaneSummary> describe(List<Pane> panes, Caller caller) {
144163
return panes.stream()
145164
.map(pane -> {

libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ void aFilterMatchingNothingSaysHowManyThereWere(Server server) {
5353
assertTrue(String.valueOf(panes.note()).contains("without 'filter'"), String.valueOf(panes.note()));
5454
}
5555

56+
/**
57+
* A model that guesses the filter's shape gets told the shape, not only that its guess was
58+
* wrong. Measured against a real agent, which guessed a plain field map first and had to spend a
59+
* call finding out.
60+
*/
61+
@Test
62+
void aFilterThatWillNotReadSaysWhatOneLooksLike(Server server) {
63+
IllegalArgumentException refused = assertThrows(
64+
IllegalArgumentException.class,
65+
() -> Listings.panes(TestCalls.on(server, "filter", java.util.Map.of("window_name", "build"))));
66+
67+
String message = String.valueOf(refused.getMessage());
68+
assertTrue(message.contains("libtmux.filter/1"), message);
69+
assertTrue(message.contains("\"node\":\"compare\""), "the shape to copy has to be in it: " + message);
70+
assertTrue(message.contains("pane_current_command"), "and the fields it may name: " + message);
71+
}
72+
73+
/** A field the pane model does not have is named alongside the ones it does. */
74+
@Test
75+
void aFieldThePaneModelLacksSaysWhichItHas(Server server) {
76+
Object document = java.util.Map.of(
77+
"schema",
78+
"libtmux.filter/1",
79+
"model",
80+
"pane",
81+
"expr",
82+
java.util.Map.of("node", "compare", "field", "window_name", "op", "equals", "value", "build"));
83+
84+
IllegalArgumentException refused = assertThrows(
85+
IllegalArgumentException.class, () -> Listings.panes(TestCalls.on(server, "filter", document)));
86+
87+
String message = String.valueOf(refused.getMessage());
88+
assertTrue(message.contains("pane_active"), message);
89+
assertTrue(message.contains("list the panes"), "and where to look instead: " + message);
90+
}
91+
5692
@Test
5793
void whoamiSaysWhichServerAndThatNoPaneIsSpecial(Server server) {
5894
Listings.Whoami whoami = Listings.whoami(server, Caller.nowhere(), Safety.MUTATING);

0 commit comments

Comments
 (0)