diff --git a/docs/rfd/drafts/D52-configurable-tool-parameter-visibility.md b/docs/rfd/drafts/D52-configurable-tool-parameter-visibility.md new file mode 100644 index 000000000..a6a03b3ab --- /dev/null +++ b/docs/rfd/drafts/D52-configurable-tool-parameter-visibility.md @@ -0,0 +1,484 @@ +# RFD D52: Configurable Tool Parameter Visibility + +- **Status**: Draft +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-08-18 +- **Extends**: [RFD 042] + +## Summary + +This RFD adds `enabled` to a tool parameter's configuration. +A parameter with `enabled = false` is absent from the JSON Schema sent to the +model, absent from `describe_tools`, and rejected if the model supplies it +anyway. +Config layers can flip the flag, so a parameter can be hidden by default and +exposed only in the context that needs it. + +Host-side default injection already exists and is unchanged, so a disabled +parameter that declares a `default` still reaches the tool with that value — +"hide this and always pass this" needs no new mechanism. + +## Motivation + +A tool parameter is useful to the model in some contexts and pure noise in +others. +Today the choice is binary and permanent: a parameter is either in the schema +for every conversation, or it does not exist. + +Take a tool that usually acts on the workspace root, but occasionally needs to +act somewhere else — a second build workspace inside the repository, or a +checkout mounted alongside it. +Two designs are available today: + +1. **Declare the parameter unconditionally.** Every conversation now carries an + argument the model must reason about and can misuse, to serve a case that + arises in a minority of them. +2. **Move the choice into `options`.** Host-configured, invisible to the model, + fixed for the whole conversation. + The model cannot act on one location and then the other, because it is not + the one choosing. + +Neither is wrong in itself, and the second is right whenever the value genuinely +is the user's to fix. +What is missing is the axis that separates them: *who chooses*, and *in which +contexts is the model allowed to*. + +Doing nothing keeps that decision welded to the parameter's declaration. +A parameter that should be invisible by default and available on demand has +nowhere to live: exposing it everywhere is noise, and moving it to `options` +puts it permanently out of the model's reach. + +## Design + +### Configuration + +`ToolParameterConfig` gains one field: + +```toml +[conversation.tools.run_build.parameters.root] +type = "string" +enabled = false +summary = "Directory to build in. Defaults to the workspace root." +``` + +A later config layer re-enables it: + +```toml +[conversation.tools.run_build.parameters.root] +enabled = true +default = "crates/some-project" +``` + +`enabled` defaults to `true`, so every existing configuration is unaffected. + +### Scope: top-level parameters only + +`ToolParameterConfig` doubles as a recursive JSON Schema node — `properties` +describes an object's fields, `items` an array's elements — so adding a field +to it adds that field everywhere in the tree. + +`enabled` is honoured only on entries directly under `parameters`. +Setting it on a nested `properties` entry or inside `items` is a **config +error**, reported with the full path to the offending entry. + +Rejecting is not the same as ignoring, and the difference matters. +A nested `enabled` would be offered by schema completion, accepted by the +parser, and then do nothing: the config loads clean while the field stays +visible to the model. +That failure is silent and invisible at exactly the moment the user believes +they have hidden something. +An error at load time costs a moment; a silently-ignored one costs a debugging +session, or a parameter the user thinks is closed. + +Top-level-only is the smaller design and it covers the motivating case. +Recursive visibility raises questions this RFD does not need to answer: whether +a disabled object property is stripped from `required` as well as `properties`, +whether a nested default is injected when the parent object is itself absent, +and what disabling `items` could mean for an array. + +The validation rule is a stopgap for a type problem. +The cleaner expression is a type split — a top-level parameter type carrying +`enabled`, a nested schema-node type without it — so the config surface cannot +state something that has no meaning, and no runtime check is needed. +That refactor is worth doing and is not required to ship the flag. + +### What "disabled" means + +Disabled is total. +The parameter does not exist as far as the model is concerned: + +- It is omitted from the JSON Schema sent to the provider. +- It is omitted from `describe_tools` output. +- It is rejected if the model supplies it anyway. + +The first two are pure schema filtering and apply to every tool source. + +The third needs work that this RFD has to include, because rejection is not +currently universal. +`validate_tool_arguments` (`crates/jp_llm/src/tool.rs`) does reject any argument +absent from `parameters`, but it is called only inside `execute_local`. +`execute_mcp` forwards the argument object straight to `mcp_client.call_tool`, +and `execute_builtin` hands it straight to the executor; neither validates. +A model that guesses a disabled parameter's name — from stale context, or +ordinary schema noncompliance, which models produce in normal use — would have +that argument delivered. + +So a disabled parameter is unreachable for local tools today and merely +unadvertised for the other two. +That is not a defensible contract for a feature whose whole claim is that the +parameter does not exist, and `parameters` already applies to MCP tools (unlike +`options`, which [RFD 042] scoped to local tools), so narrowing this RFD to +local tools would contradict the field it extends. + +**This RFD therefore adds one source-independent step, scoped to the visibility +contract alone.** For every tool source, before dispatch: + +1. Reject an argument naming a disabled parameter. +2. Inject a disabled parameter's configured `default`. + +That is the whole of it. + +### What this step deliberately does not do + +Everything else stays exactly where it is. +Default injection for *enabled* parameters, and rejection of arguments that name +no parameter at all, remain inside `execute_local` and remain local-only. + +The temptation is to hoist all of `apply_parameter_defaults` and +`validate_tool_arguments` above the dispatch and unify the three sources in one +go. +That would be a larger, unrelated behavioural change: + +- **MCP servers would start receiving arguments they never received.** JSON + Schema treats `default` as an annotation, explicitly *not* a value to populate + on the caller's behalf, and a server may distinguish an absent field from an + explicitly-supplied one even where the values coincide. + Injecting into an MCP call changes the wire format for every MCP tool that has + a configured default. +- **Builtin tools would start rejecting arguments** that reach their executors + untouched today. + +Neither has anything to do with parameter visibility. +Unifying host-side argument handling across sources is worth doing on its own +terms, with its own compatibility analysis; it is not this RFD's to smuggle in. + +Because the step is scoped this way, a configuration with no disabled parameters +produces byte-identical behaviour on every path. + +### Disabled and `required` are orthogonal + +Hiding a parameter says nothing about whether the tool needs a value for it. +The two are separate axes and both combinations are meaningful: + +| | `enabled = true` | `enabled = false` | +| ------------------ | -------------------- | ---------------------------------------------------------- | +| `required = false` | Model may supply it | Tool falls back to its own default | +| `required = true` | Model must supply it | `default` is mandatory; the config is rejected without one | + +`required` describes the schema the model is held to; `enabled` describes +whether the model sees that schema at all. +Disabled plus required is coherent: the value is mandatory *and* not the model's +to choose. + +Two rules make that concrete, and both are needed — without them the +combination is merely undefined. + +**Validation runs against the enabled set.** A disabled parameter is not +required of the model, because the model cannot see it. +Validating against the full set instead would mark a disabled parameter +permanently missing and fail every single call. + +**Disabled + required + no `default` is rejected at config resolution.** Given +the rule above, such a parameter would otherwise be silently absent: the model +cannot supply it, nothing injects it, validation no longer asks for it, and the +tool — which by design knows nothing about visibility — cannot tell it apart +from an omitted optional. +A value declared mandatory would simply never arrive. +That is a stable mistake in configuration, not a runtime condition, so it is +caught once at load rather than on every invocation. +`reject_access_on_non_local_tools` is the precedent: an incoherent combination +refused at config time rather than half-honoured later. + +### Defaults, mostly already solved + +`apply_parameter_defaults` already fills in a configured `default` for any +parameter the arguments omit, and already runs before validation. +It exists because models routinely omit defaulted parameters even when they are +marked required. + +A disabled parameter is always omitted by definition, so it always takes its +default: + +```toml +[conversation.tools.run_build.parameters.root] +enabled = false +default = "crates/some-project" +``` + +The tool receives `root = "crates/some-project"` and cannot tell the difference +between that and the model having passed it. +"Hide this parameter and always pass this value" therefore needs no new concept +— only the ability to hide the parameter. + +For local tools this is entirely existing behaviour. +For MCP and builtin tools, where nothing injects today, the visibility check +carries the disabled parameter's default and nothing else; their handling of +*enabled* parameters is untouched. + +**Injection behaviour is unchanged for enabled parameters, on every source.** An +earlier draft of this RFD proposed restricting injection to disabled parameters; +that would have been a breaking change. +An enabled, required parameter with a default that the model omits succeeds +today and must continue to — turning it into a failed tool call would persist +that failure into the conversation and feed it back to the model on the next +cycle. + +### Two argument maps, and where each is used + +The stored `ToolCallRequest` keeps the model's raw arguments. +That is deliberate and unchanged: stored requests are replayed to providers as +conversation history, so persisting an injected value would hand the model back +a parameter this feature exists to hide. + +Preprocessing therefore produces a second, **effective** map, and everything +that acts on or reports the call uses that one: + +| Map | Contents | Used for | +| --------- | ---------------------------------------- | ---------------------------------------------------------------------- | +| Stored | Exactly what the model emitted | The conversation stream; replay to providers as history | +| Effective | Stored, plus disabled-parameter defaults | Permission prompts, argument formatting, rendering, tracing, execution | + +**The ordering is the point.** Preprocessing happens when the executor is +created — before `permission_info` builds a prompt — not on the way into the +tool. + +Today injection happens inside `execute_local`, well after the approval prompt +is rendered from `request.arguments`. +For an enabled parameter with a default that is an occasional cosmetic mismatch. +For a disabled one it is systematic: a disabled parameter is *always* absent +from the model's output, so its configured value would *always* be missing from +the prompt. + +A tool with `run = "ask"` and a disabled `root` defaulting to a second build +directory would ask the user to approve a call showing no `root` at all — +implying the workspace root — and then run somewhere else. +The user's consent would not describe the operation performed, which defeats the +only thing `ask` mode exists to do. + +After an `edit` permission changes the arguments, preprocessing and validation +run again on the edited map, so the final prompt and the executed call still +agree. + +The residual cost is unchanged from today: a tool's observed arguments do not +match the stored request, which matters to anything auditing or replaying tool +calls from the stream. +What this RFD fixes is that the *human being asked to approve* now sees what the +tool will actually receive. + +### Tools stay ignorant of visibility + +Tools are not told which parameters are disabled. +A tool reads its arguments and applies its own fallback when one is missing, +exactly as today. + +The alternative — exposing the disabled set so a tool can branch on it — is +deliberately not adopted. +It pushes visibility awareness into every tool that wants a context-dependent +default, when `default` injection already covers the motivating need. +It can be added later if a tool appears that genuinely cannot express itself +through a default value. + +### Relationship to tool options + +Options and parameters answer different questions, and this feature sharpens the +line between them. + +An **option** is a setting the model has no business choosing: a credential, a +feature toggle, an environment detail. +It is host-configured and never appears in a schema. + +A **parameter** is an input the model could sensibly choose. +Until now, a parameter that should be hidden in most contexts had to be demoted +to an option to get out of the way — which also removed the model's ability to +choose it in the contexts where it should. +`enabled` removes that forced trade. + +The test for which to reach for is unchanged and simple: could the model ever +sensibly pick this value? +If yes, it is a parameter, and `enabled` decides where it is offered. + +## Drawbacks + +**A parameter's existence becomes layer-dependent, and nothing reports the +result.** Reading a tool's config no longer tells you what the model sees; you +need the merged view, and no command produces one. +`jp config show` prints a commented skeleton of available keys, or defaults with +`--defaults`; neither reflects merged configuration, and the command loads no +conversation, so conversation-layer state is out of reach entirely. +The failure mode is a user wondering why the model ignores a documented +argument, with no way to check. + +[RFD 060] would close this — a focused +`--explain=conversation.tools..parameters..enabled` is exactly the +diagnostic — but it is in Discussion, so this RFD ships without one. + +**Two axes multiply.** `enabled` times `required` times `default` is eight +combinations, one of which is refused at config resolution. +That is a third axis to hold in mind when reading a tool's parameters, and the +one illegal combination is discoverable mainly by tripping over it. + +## Alternatives + +**Leave it in `options`.** Zero work, and correct whenever the value should be +fixed for a conversation. +Rejected as the general answer because it cannot express "the model may choose +this here", which is the actual gap. + +**Reversing [RFD 042].** This RFD proposes what RFD 042 considered and rejected, +so the reversal needs stating rather than assuming. +RFD 042's Alternatives dismissed "hidden parameters that the LLM doesn't see but +the tool reads" on two grounds, and the intervening design answers both: + +- *"The LLM might hallucinate values for these hidden parameters."* It may, and + the call is now rejected. + The source-independent check (see above) makes that true for every tool + source, which is precisely what closes 042's objection. +- *"Validation might reject tool calls that omit them."* It does not. + `apply_parameter_defaults` fills the value in before validation runs, so an + omitted-but-defaulted parameter is not a failure. + That machinery post-dates RFD 042. + +What survives from 042 is its central distinction, and this RFD keeps it: a +setting the model should never choose is an option, not a hidden parameter. +What changes is narrower — a parameter that is model-meaningful *in some +contexts* no longer has to be demoted to an option to stay out of the way in the +others. + +**Tool groups ([RFD 055], [RFD 056], [RFD 057]).** Swap whole tool definitions +per context, so a context supplies a variant with the parameter declared. +Heavier: it duplicates a tool definition to vary one field, and the group +machinery is still in Discussion. + +**A `hidden` flag on the parameter instead of `enabled`.** Same mechanism, +different word. +`hidden` suggests presentation-only, which would be a mischaracterisation — the +parameter is rejected, not merely undisplayed. + +**Follow the tool-level `state` / `allow_toggle` split ([RFD 081]).** Tools +distinguish "is it on" from "may a later layer change that". +Parameters could too. +Not adopted: no case yet needs to pin a parameter's visibility against later +layers, and a single boolean is the smaller step. +The split remains available if such a case appears. + +## Non-Goals + +- **Conditional visibility.** No expressions, no "enable when another parameter + is set". + Static per-layer configuration only. +- **Per-call visibility.** The set is fixed when tool definitions are resolved + for an invocation. +- **Recursive visibility.** `enabled` on nested `properties` or `items` is a + config error, not a feature; see the scope section. +- **Changing `options`.** They keep their meaning; this only removes the cases + where a parameter was forced into them. +- **Changing default injection for enabled parameters.** Existing behaviour is + preserved exactly, on every tool source. +- **Unifying host-side argument handling across tool sources.** Worth doing, but + it is a separate change with its own compatibility analysis; see "What this + step deliberately does not do". +- **Telling tools which parameters are disabled.** See above. + +## Risks and Open Questions + +**May the user supply a disabled parameter when editing a call?** `run = "edit"` +lets the user rewrite arguments before approval. +`enabled = false` means "not the model's to choose", and the user is the host, +not the model — so a user-supplied value is arguably legitimate where a +model-supplied one is not. +Validation as described cannot tell the two apart: it sees one argument map. +Rejecting the edit blocks a defensible host override; accepting it reopens the +channel for anything that can write to that map. +This needs deciding during implementation rather than falling out of it. + +**MCP tools whose server-side schema is richer than JP's config.** JP rejects an +argument naming a parameter it has been told is disabled, which presumes the +configured `parameters` map describes the same parameter the server means. +Worth confirming against a server that declares parameters JP's config does not +mirror. + +**Sequencing against [PR 998].** That open pull request validates resolved tool +schemas and touches the same resolution path. +Whichever lands second inherits the merge; worth agreeing an order rather than +discovering it. + +**[RFD D10] moves in the opposite direction.** It relocates parameter defaults +and argument validation *into* `StdioRuntime`, one runtime among several, while +this RFD places the visibility check above the source dispatch precisely because +it is source-independent. +D10 is a Draft, so it does not block this; but if this lands first, D10's +migration section needs revising to keep the visibility contract out of the +per-source runtimes. + +## Implementation Plan + +**Phase 1: The flag and the schema filter.** Add `enabled` to +`ToolParameterConfig`, defaulting to `true`. +Filter disabled parameters out of the schema sent to providers and out of +`describe_tools`. +Validate against the enabled set, reject `enabled` on nested schema nodes, and +reject disabled + required without a `default` at config resolution. +Mergeable on its own; with nothing configured, behaviour is unchanged. + +**Phase 2: The visibility check, above the dispatch.** Reject arguments naming a +disabled parameter and inject disabled-parameter defaults, for every tool +source, producing the effective argument map. +Run it at executor creation so the map reaches the approval prompt, and re-run +it after an `edit`. +Depends on Phase 1. +This is the phase that makes "disabled is total" true and keeps approval honest; +until it lands, Phase 1 hides parameters without closing the guess path. + +Injection for enabled parameters needs no phase: `apply_parameter_defaults` +already does the right thing, and is left alone. + +With no parameter configured as disabled, both phases are no-ops: schemas, +arguments, and the wire format for every tool source are identical to today. + +## References + +- `crates/jp_config/src/conversation/tool.rs` — `ToolParameterConfig`, + including its recursive `properties` and `items`. +- `crates/jp_llm/src/tool.rs` — `apply_parameter_defaults`, + `validate_tool_arguments`, and the three `execute_*` paths that do not + currently share them. +- `crates/jp_cli/src/cmd/query/tool/executor.rs` — `permission_info` and + `arguments`, which read the stored request; where the effective map has to be + in place by. +- `crates/jp_cli/src/cmd/config/show.rs` — what `jp config show` actually + prints. +- [RFD D10] — unified tool execution model; moves defaults and validation into + per-source runtimes, which this RFD's visibility check must sit above. +- [RFD 042] — tool options, and the rejected "hidden parameters" alternative + this RFD revisits. +- [RFD 055], [RFD 056], [RFD 057] — tool groups. +- [RFD 060] — config explain, the natural home for a visibility diagnostic. +- [RFD 081] — the tool-level `state` / `allow_toggle` split. +- [Issue 208] — tool templates with parameter constraints (`fixed`, `default`, + `enum`, `remove`) and host-side call validation. + This RFD is the narrow first slice of that design: one boolean on the existing + `parameters` map, no templating, no multi-exposure. + The vocabularies should converge rather than diverge — `enabled = false` is + 208's `remove`, and `enabled = false` with a `default` is its `fixed`. +- [PR 998] — resolved tool schema validation, in flight against the same path. + +[Issue 208]: https://github.com/dcdpr/jp/issues/208 +[PR 998]: https://github.com/dcdpr/jp/pull/998 +[RFD 042]: ../042-tool-options.md +[RFD 055]: ../055-tool-groups.md +[RFD 056]: ../056-group-configuration-defaults.md +[RFD 057]: ../057-group-configuration-overrides.md +[RFD 060]: ../060-config-explain.md +[RFD 081]: ../081-decompose-tool-enable-into-state-and-allow_toggle.md +[RFD D10]: D10-unified-tool-execution-model.md