Skip to content

[Fix and Refactor] Fix and enhance config slash and reload - #26

Merged
wangxingjun778 merged 5 commits into
mainfrom
feat/deep_loop
Aug 5, 2026
Merged

[Fix and Refactor] Fix and enhance config slash and reload#26
wangxingjun778 merged 5 commits into
mainfrom
feat/deep_loop

Conversation

@wangxingjun778

Copy link
Copy Markdown
Member

No description provided.

Changing a setting from the TUI failed in a way that looked like a path problem
but was a capability gap. Of 56 registered tools, none could read or write
configuration, so "switch the model to X" left the model with only file_read /
shell_run: it guessed at ~/.leapflow/profiles and ~/.leapflow/config/user.yaml,
the workspace sandbox refused both, and the refusal wrongly suggested that
approval could lift the boundary — so the user approved something unrelated and
still failed.

Two earlier attempts had added "do not probe <workspace>/.leapflow/config.json"
to file_read's description. That forbade one path while listing the real ones,
which both failed to stop the probing (the goal was unchanged, so the model
tried elsewhere) and handed it fresh targets.

Rather than widen allowed_roots for config paths — which would keep the model
editing raw YAML, bypassing type coercion, scope checks, vault-backed secrets
and hot-reload semantics — this adds the missing capability, so the sandbox
stays strict and the layout becomes an implementation detail again:

- config_list / config_get / config_set delegate to ConfigService, the same
  control plane behind `leap config` and `/config`. They take a key, never a
  path, so ToolExecutionContext is never consulted.
- Reads are read_only/low-cost and therefore land in the CORE disclosure floor
  automatically (the manifest derives it); the write stays out of CORE. Both
  fall out of x_leapflow metadata rather than a hand-maintained list.
- config_set is classified mutating_idempotent: re-setting the same value
  converges, which keeps it out of the uncertain-effect path that would
  otherwise stall a legitimate retry.
- A write reloads the live session through the Context's existing
  reload_runtime_config_if_changed, because otherwise the value lands on disk
  while the in-process settings singleton keeps the old one and the model's
  read-back looks like a failed write.
- Unknown keys return fuzzy suggestions (difflib), so typos and dropped
  separators are corrected in the same turn instead of falling back to probing.

Writes are gated. Deep review found that x_leapflow's requires_approval only
feeds capability disclosure and does not gate execution, so the tool consults an
approval gate explicitly, wired from both the in-process Context and the daemon
ApprovalCoordinator alongside the shell/file/gateway gates. This matters because
guardrail.enabled, confirm.default_level and codegen.sandbox are all writable —
an unguarded config_set would let the model switch off its own supervision. It
fails closed with no gate installed, denies when the gate raises, and passes the
key and metadata but never the value, so a credential cannot reach a prompt or
the audit trail.

Also in this change:
- layout: skip the workspace-local manifest when the workspace's .leapflow *is*
  the LeapFlow home (typically $HOME). That collision is what left a stray
  workspace.yaml beside config/, profiles/ and secrets/, and is the concrete
  reason two .leapflow directories looked entangled. The profile-side manifest
  is written unconditionally, so nothing is lost.
- sandbox refusals no longer imply approval can lift the boundary, and when the
  path is LeapFlow's own config or vault they name the config tools. The
  classification comes from layout.describe_path, not string matching.
- file_read's description points at the config tools and no longer enumerates
  any LeapFlow path.

20 new tests plus two rewritten disclosure tests; each guard was verified to
bite by sabotaging what it protects. Suite 1297 -> 1301 passing, ruff clean.
Symptom: after `/config llm set --model qwen3.8-max`, config_get reported
qwen3.8-max and the change was live, but the status bar kept showing
qwen3.7-plus.

The config was never the problem. The TUI is a separate process: it seeds
runtime_model_name from its own settings at startup and afterwards can only
learn about a change from metadata the daemon sends back. Two paths could have
carried it, and neither did:

- /config returns through command_execute as a payload, and the payload already
  contained "model" — but the daemon-mode branch never read it. /host has
  exactly this kind of write-back; config was missing one.
- The chat loop's "Configuration reloaded in leapd." notification is
  change-detection based, and a mutation force-reloads first, so the signature
  already matches and the notification never fires. This is why sending another
  message did not help either.

Fixes, matched to each path:
- engine_context_metadata now includes llm_model, so every status/stream path
  reports the model actually in use. This also covers the config_set *tool*,
  which changes settings mid-turn and has no payload path at all.
- the daemon-mode TUI branch applies model and context length from a config
  mutation payload, mirroring the /host write-back.
- the mutation payload carries llm_context_length alongside model: switching
  models usually changes it, so the 0/1M readout was stale for the same reason.

An empty model is omitted rather than written, so an unset value cannot blank
out what the bar already shows.

Both new guards were verified to bite by removing what they cover. Suite
1301 -> 1303 passing, ruff clean.
config_set failed at runtime with "ApprovalOrchestrator.check() takes 2
positional arguments but 4 were given", so no model change through the tool ever
went through.

My mistake when adding the gate: I copied the call shape from file_write's
gate.check(path, content, mode, meta) without checking what was actually being
passed in. file_write hands set_file_write_gate a _FileWriteGate *adapter* that
translates into an ActionDescriptor, whereas config wiring passes the
orchestrator itself — and its check() is the shell-oriented single-argument form.
The fallback made it worse: raising inside an `except TypeError` block escapes
the sibling `except Exception`, so the error surfaced raw instead of failing
closed.

Now uses the orchestrator's native evaluate(ActionDescriptor) with
ActionKind.RUNTIME_CONFIGURE, which is cleaner than adding another adapter and
picks up risk assessment, policy, existing grants and the audit trail. Verified
end-to-end against the real orchestrator: a change is rated HIGH and reaches the
prompt, an approval writes through, a denial leaves the setting untouched.

The fakes were part of the problem — they implemented the same wrong signature,
so the suite stayed green while production was broken. They now implement
evaluate(), and two tests drive the production ApprovalOrchestrator directly. A
gate exposing only the shell-style check() is denied rather than silently
allowed.

Suite 1303 -> 1307 passing; reintroducing the check() call fails 7 of them.
@wangxingjun778
wangxingjun778 merged commit ef5715a into main Aug 5, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant