|
| 1 | +# HINTS-STRUCTURED — machine-parseable next-action objects alongside road-sign strings |
| 2 | + |
| 3 | +## Status |
| 4 | +Proposal — not yet implemented. |
| 5 | + |
| 6 | +**Tracks:** [#195](https://github.com/HumanBean17/java-codebase-rag/issues/195) (item 7). |
| 7 | + |
| 8 | +**Depends on (landed):** v1–v4 hint catalogs (`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`, `HINTS-V2`, `HINTS-V3`, `HINTS-V4-SUCCESS-PATH-PROPOSE.md`), `mcp_hints.py` `generate_hints`, response models in `mcp_v2.py`. |
| 9 | + |
| 10 | +## Problem Statement |
| 11 | + |
| 12 | +Battle testing (issue #195) shows agents copying `hints` strings literally into MCP calls. Templates like: |
| 13 | + |
| 14 | +``` |
| 15 | +clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT']) |
| 16 | +``` |
| 17 | + |
| 18 | +use **Python-style list syntax** (`['…']`). When an agent copies that into a JSON `ids` parameter, it sends `"['<id>']"` — invalid JSON (single quotes), which FastMCP's `json.loads` rejects. The call fails with `Unknown id prefix for '['…]'`. |
| 19 | + |
| 20 | +The root cause is that `hints: list[str]` embeds pseudo-call syntax that is: |
| 21 | +1. **Not machine-parseable** — agents must reverse-engineer tool name, positional args, and kwargs from freeform text. |
| 22 | +2. **Syntactically ambiguous** — Python lists vs JSON arrays, single vs double quotes, positional vs keyword args. |
| 23 | +3. **Unreliable to fix by coercion** — heuristic `_coerce_ids()` in `mcp_v2` handles one symptom but not the underlying gap. |
| 24 | + |
| 25 | +String hints remain valuable as **human-readable** road signs (operator logs, debug traces). But LLM agents need a **structured** form they can use directly. |
| 26 | + |
| 27 | +## Proposed Solution |
| 28 | + |
| 29 | +Add an optional `hints_structured` field to all five MCP output models. Each element is a typed object `{tool, args}` that maps 1:1 to an MCP tool call. Generation reuses the same trigger logic as string hints; rendering switches from template strings to structured arg dicts. |
| 30 | + |
| 31 | +### Shape |
| 32 | + |
| 33 | +```python |
| 34 | +class StructuredHint(BaseModel): |
| 35 | + tool: Literal["search", "find", "describe", "neighbors", "resolve"] |
| 36 | + args: dict[str, Any] |
| 37 | +``` |
| 38 | + |
| 39 | +All five `*Output` models gain: |
| 40 | + |
| 41 | +```python |
| 42 | +hints_structured: list[StructuredHint] = Field(default_factory=list) |
| 43 | +``` |
| 44 | + |
| 45 | +### Mapping from string templates |
| 46 | + |
| 47 | +Every `TPL_*` constant that embeds a `neighbors(…)` / `search(…)` / `find(…)` / `resolve(…)` call pattern gains a **structured counterpart** — a function or constant that returns `StructuredHint` instead of a formatted string. |
| 48 | + |
| 49 | +Example mapping (describe type rollup): |
| 50 | + |
| 51 | +| String template | Structured equivalent | |
| 52 | +|---|---| |
| 53 | +| `clients via members: neighbors(['{id}'],'out',['DECLARES.DECLARES_CLIENT'])` | `StructuredHint(tool="neighbors", args={"ids": [id], "direction": "out", "edge_types": ["DECLARES.DECLARES_CLIENT"]})` | |
| 54 | +| `routes via members: neighbors(['{id}'],'out',['DECLARES.EXPOSES'])` | `StructuredHint(tool="neighbors", args={"ids": [id], "direction": "out", "edge_types": ["DECLARES.EXPOSES"]})` | |
| 55 | +| `handler: neighbors(['{id}'],'in',['EXPOSES'])` | `StructuredHint(tool="neighbors", args={"ids": [id], "direction": "in", "edge_types": ["EXPOSES"]})` | |
| 56 | +| `no match — try search(query='{identifier}') for ranked fuzzy lookup` | `StructuredHint(tool="search", args={"query": identifier})` | |
| 57 | +| `no matches — try resolve(identifier='{kind}', hint_kind='…')` | `StructuredHint(tool="resolve", args={"identifier": identifier, "hint_kind": kind})` | |
| 58 | + |
| 59 | +**Prose-only hints** (e.g. `results look weak — narrow the query`, `many CALLS — consider filtering`) map to `StructuredHint` with only `tool` set and an `args` dict that encodes the advisory signal as a structured recommendation, **not** a direct tool call. Agents can inspect `tool` to decide if the hint is actionable. |
| 60 | + |
| 61 | +| String | Structured | |
| 62 | +|---|---| |
| 63 | +| `results look weak — narrow the query or try find(role=…)` | `StructuredHint(tool="find", args={"filter": {"role": "SERVICE"}})` (suggests a concrete starting filter) | |
| 64 | +| `{n} CALLS — consider filtering by target microservice` | `StructuredHint(tool="neighbors", args={"ids": [id], "direction": "out", "edge_types": ["CALLS"], "edge_filter": {}})` (placeholder; agent fills in filter) | |
| 65 | + |
| 66 | +**Open question:** whether prose-only hints should use a separate `action: "suggest"` field or embed a partial `args` — see Open Questions. |
| 67 | + |
| 68 | +### Coexistence with `hints` |
| 69 | + |
| 70 | +- `hints: list[str]` — **unchanged**, backward compatible. Remains the human-readable field. |
| 71 | +- `hints_structured: list[StructuredHint]` — new field, machine-parseable. |
| 72 | +- Both fields are populated from the same trigger logic. Same priority, same cap (5), same dedup. |
| 73 | +- Clients that ignore `hints_structured` continue working identically. |
| 74 | + |
| 75 | +### Generation refactor |
| 76 | + |
| 77 | +`generate_hints` returns `(list[str], list[StructuredHint])` instead of `list[str]`. Call sites in `mcp_v2.py` destructure the tuple: |
| 78 | + |
| 79 | +```python |
| 80 | +str_hints, struct_hints = generate_hints("describe", hint_payload) |
| 81 | +return DescribeOutput(success=True, record=record, hints=str_hints, hints_structured=struct_hints) |
| 82 | +``` |
| 83 | + |
| 84 | +Alternatively (less invasive): a parallel `generate_structured_hints` function that reads the same payload and returns `list[StructuredHint]`, called alongside `generate_hints` at each call site. This avoids changing the `generate_hints` return type but duplicates trigger logic. |
| 85 | + |
| 86 | +**Recommended:** single `generate_hints` returning both. The trigger logic is already complex; maintaining two parallel generators is fragile. The return-type change is internal to `mcp_hints.py` + `mcp_v2.py`; no external API break. |
| 87 | + |
| 88 | +### Prose-only hints: `actionable` flag |
| 89 | + |
| 90 | +Add an optional field to `StructuredHint`: |
| 91 | + |
| 92 | +```python |
| 93 | +class StructuredHint(BaseModel): |
| 94 | + tool: Literal["search", "find", "describe", "neighbors", "resolve"] |
| 95 | + args: dict[str, Any] |
| 96 | + actionable: bool = True # False = advisory, args may be partial/placeholder |
| 97 | +``` |
| 98 | + |
| 99 | +When `actionable=False`, the hint is a recommendation (e.g. "consider filtering by role"), not a direct call. Agents can skip or use as guidance. Direct-call hints (`neighbors(['id'],'out',['EXPOSES'])`) always set `actionable=True` with complete args. |
| 100 | + |
| 101 | +This avoids over-engineering prose-only hints into fake tool calls while still giving agents structured data for every hint slot. |
| 102 | + |
| 103 | +### Batch-placeholder hints (N2, N3, N4, N5, N6, N7 from v4) |
| 104 | + |
| 105 | +v4 templates like `HTTP targets: neighbors(client_ids,'out',['HTTP_CALLS'])` use batch placeholders (`client_ids`, `route_ids`) that are never substituted — the agent is expected to pick ids from results. For structured hints, these map to: |
| 106 | + |
| 107 | +```python |
| 108 | +StructuredHint( |
| 109 | + tool="neighbors", |
| 110 | + args={"ids": [], "direction": "out", "edge_types": ["HTTP_CALLS"]}, |
| 111 | + actionable=False, # ids placeholder — agent must fill from results |
| 112 | +) |
| 113 | +``` |
| 114 | + |
| 115 | +Alternatively, the structured hint could carry `args.ids` populated from the payload's result ids when available. See Open Questions. |
| 116 | + |
| 117 | +## Scope |
| 118 | + |
| 119 | +- New `StructuredHint` model in `mcp_v2.py` |
| 120 | +- `hints_structured` field on `SearchOutput`, `FindOutput`, `DescribeOutput`, `NeighborsOutput`, `ResolveOutput` |
| 121 | +- Refactor `generate_hints` in `mcp_hints.py` to return `(list[str], list[StructuredHint])` |
| 122 | +- Update all 5 call sites in `mcp_v2.py` |
| 123 | +- Tests in `tests/test_mcp_hints.py` |
| 124 | +- `MCP_HINTS_FIELD_DESCRIPTION` update in `mcp_hints.py` |
| 125 | +- README mention under MCP tool reference `hints` paragraph |
| 126 | + |
| 127 | +## Schema / Ontology / Re-index impact |
| 128 | + |
| 129 | +- Ontology bump: **not required** (MCP response shape only, no graph/index changes) |
| 130 | +- Re-index required: **no** |
| 131 | +- Config/tool surface changes: new response field `hints_structured` on all five tools (additive; no removals) |
| 132 | + |
| 133 | +## Tests / Validation |
| 134 | + |
| 135 | +| Test name | Asserts | |
| 136 | +|---|---| |
| 137 | +| `test_structured_hint_describe_type_rollup_clients` | `hints_structured` contains `StructuredHint(tool="neighbors", args={"ids": [id], …})` matching string `TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS` | |
| 138 | +| `test_structured_hint_describe_type_rollup_routes` | Same for `DECLARES.EXPOSES` | |
| 139 | +| `test_structured_hint_describe_method_overriders` | `OVERRIDDEN_BY` mapped correctly | |
| 140 | +| `test_structured_hint_find_route_handler` | F1 → `StructuredHint(tool="neighbors", args={"ids": [id], "direction": "in", "edge_types": ["EXPOSES"]})` | |
| 141 | +| `test_structured_hint_resolve_none_search` | `StructuredHint(tool="search", args={"query": identifier})` | |
| 142 | +| `test_structured_hint_resolve_none_find_route` | `StructuredHint(tool="find", args={"kind": "route", "filter": {"path_prefix": seed}})` | |
| 143 | +| `test_structured_hint_neighbors_empty_wrong_kind` | `actionable=False` on structural hint | |
| 144 | +| `test_structured_hint_prose_only_not_actionable` | weak-score / high-fanout hints have `actionable=False` | |
| 145 | +| `test_structured_hints_cap_5` | `len(hints_structured) <= 5` on payloads that generate many triggers | |
| 146 | +| `test_structured_hints_dedup` | Identical `(tool, args)` deduped like string hints | |
| 147 | +| `test_structured_hints_parity_with_string_hints` | For every output where `hints != []`, `len(hints_structured) == len(hints)` (1:1 mapping) | |
| 148 | +| `test_structured_hint_round_trip` | Building structured hint args into an actual MCP call succeeds (integration with `neighbors_v2`) | |
| 149 | + |
| 150 | +Regression: all existing string-hint tests continue passing unchanged. |
| 151 | + |
| 152 | +## Open Questions ([TBD]) |
| 153 | + |
| 154 | +1. **Should `generate_hints` return a tuple or should `generate_structured_hints` be a separate function?** |
| 155 | + — Recommended: single function returning `(list[str], list[StructuredHint])`. Keeps trigger logic unified. |
| 156 | + |
| 157 | +2. **Should batch-placeholder structured hints (v4 N2–N7) carry concrete result ids in `args.ids`?** |
| 158 | + — Recommended: **yes** when available from the payload. If the payload contains `results[*].other.id`, populate `args.ids` with those ids and set `actionable=True`. If ids cannot be extracted (meta-hints), leave `args.ids` empty with `actionable=False`. |
| 159 | + |
| 160 | +3. **Should prose-only hints (weak-score, high-fanout) have `actionable=False` with partial args, or should they be omitted from `hints_structured` entirely?** |
| 161 | + — Recommended: include with `actionable=False`. Agents that only process actionable hints can filter; agents that want all signals get structured data either way. Maintains 1:1 parity with string hints. |
| 162 | + |
| 163 | +4. **Should `StructuredHint.args` be validated against MCP tool schemas?** |
| 164 | + — Recommended: **no** in v1. Args are advisory; strict validation couples hints to tool signature changes. Add later if traces show agents breaking on stale args. |
| 165 | + |
| 166 | +5. **Should `hints_structured` be added to `MCP_HINTS_FIELD_DESCRIPTION` or get its own field description?** |
| 167 | + — Recommended: own field description on the new field, referencing `hints` for the human-readable form. |
| 168 | + |
| 169 | +6. **String hint template refactor: should string templates be derived from structured hints (DRY), or kept as independent constants?** |
| 170 | + — Recommended: **keep independent** in v1. String templates have char caps and prose labels ("clients via members:") that don't map to `StructuredHint` fields. Deriving strings from structured hints risks losing the concise human-readable format. Revisit if drift becomes a problem. |
| 171 | + |
| 172 | +## Out of scope |
| 173 | + |
| 174 | +- Removing or deprecating `hints: list[str]` (kept indefinitely) |
| 175 | +- Changing `neighbors_v2` argument parsing or adding `_coerce_ids()` (separate fix from issue #195) |
| 176 | +- Reindexing or ontology changes |
| 177 | +- Per-row structured hints (output-level only, matching v1–v4 discipline) |
| 178 | +- `hints_version` field |
| 179 | +- Conditioning on `attrs.match` / confidence values |
| 180 | +- FastMCP `ast.literal_eval` fallback (upstream; out of scope) |
| 181 | + |
| 182 | +## Sequencing / Follow-ups |
| 183 | + |
| 184 | +**Single PR** recommended (response model + generation + tests): |
| 185 | + |
| 186 | +1. Add `StructuredHint` model and `hints_structured` field to all outputs |
| 187 | +2. Refactor `generate_hints` return type |
| 188 | +3. Map all existing template triggers to structured equivalents |
| 189 | +4. Update call sites in `mcp_v2.py` |
| 190 | +5. Add tests |
| 191 | +6. README mention |
| 192 | + |
| 193 | +After landing: close issue #195 item 7. Items 1–6 from the issue (string template fix, coercion, docs) remain independent and can land before or after this proposal. |
0 commit comments