Skip to content

Refactor/simplify core - #47

Open
Barent wants to merge 23 commits into
mainfrom
refactor/simplify-core
Open

Refactor/simplify core#47
Barent wants to merge 23 commits into
mainfrom
refactor/simplify-core

Conversation

@Barent

@Barent Barent commented Sep 9, 2026

Copy link
Copy Markdown
Owner

No description provided.

…try / artifacts

R1 (simplify) progress, all tests green:

- natshell/agent/intent.py: is_plan_request / is_analysis_request (was inline
  in loop.py). Old names re-exported from loop for backward-compat.

- natshell/agent/events.py: AgentEvent + EventType (was loop-local). Re-exported
  from loop; loop no longer redefines them.

- natshell/agent/repetition_guard.py: RepetitionGuard dataclass owns all six
  inline detectors (duplicates, re-reads, re-fetches, command families,
  near-identical commands, edit-failure escalation) that used to be a wall of
  state + string-building inside handle_user_message. Loop just calls
  observe() and reads the Observation.

- natshell/agent/sudo_retry.py: self-contained async "prompt -> cache ->
  prepend -> re-classify -> confirm -> re-execute" flow, collected via an
  on_event callback so event order is preserved.

- natshell/agent/context_manager.py: new compress_artifacts() owns the
  write_file-content elision + long-tool-result truncation that used to live
  in agent.loop._compress_old_messages (now a thin delegate).

- natshell/app.py: the three triplicated confirm / password callback closures
  in run_agent / run_plan_generation / run_plan are now three one-liners
  delegating to NatShellApp._confirm_callback / _password_callback /
  _gated_confirm_callback.

loop.py is 1398 → 1140 lines after this refactor; suite green (1598 tests).
This is checkpoint 1 of the R1 sweep; ToolCallGrammar (R1) and the R2
perf features (streaming, parallel read-only tools, cache-stable prefix,
real-tokenizer budget) still to follow.
First commit of the ToolCallGrammar split.  Common primitives that the
per-family modules (qwen / mistral / gemma) will all share live in
``natshell.inference.grammars.common``:

- CODE_FENCE_JSON_RE / THINK_RE / THINK_UNCLOSED_RE (shared regexes)
- parse_structured_tool_calls (the OpenAI-shaped ``tool_calls`` pass)
- try_bare_json_recovery   (the two near-identical bare-JSON blocks in the
                            current engine become one function)
- is_bare_tool_json        (single source of truth for "is this leftover text
                            actually a tool-call dict")
- strip_prose_markers      (the 7-marker strip cascade)
- is_degenerate_output     (moved verbatim from local.py)
- new_tool_call_id         (``uuid4()[:9]`` was repeated 14 times)

The engine itself is unchanged in this commit — the family modules are next.
Suite still green (1598 tests).
Second installment of the ToolCallGrammar split:

- common.py grows the Grammar base class (render_tools / parse_native /
  recover / scrub_recovered / normalize_messages) plus the two shared
  message normalizers (strict alternation, tool-result-to-user) and
  format_tool_entries — the per-family modules now only carry what is
  genuinely family-specific.
- qwen.py:  XML tool-call parsing (the default/fallback family),
  prompt rendering (plain + compact).
- mistral.py: [TOOL_CALLS] JSON-array parsing, flat-arg fallback,
  the stricter bare-JSON recovery + scrub, strict-alternation
  normalization.

WIP: gemma.py and the local.py rewiring are the remaining two steps
of this item — nothing imports the modules yet, so behaviour is
unchanged. Suite green (1598 tests).
Completes the R1§3 ToolCallGrammar extraction. The model family's tool-call
wire format is now split out of local.py into the inference/grammars package:

  - qwen.py    (landed earlier)   — <tool_call> XML wire format
  - mistral.py (landed earlier)   — [TOOL_CALLS] JSON wire format
  - gemma.py   (this commit)      — <|tool_call>call:NAME{...} native format,
                                    <|channel> think blocks, special-token
                                    scrubbing, tool-message conversion
  - common.py  (this commit)      — shared pipeline primitives + the Grammar
                                    protocol + the parse() pipeline that each
                                    family composes, including the default
                                    ALL_FAMILY_STRIP union
  - __init__.py                   — registry: get_grammar() + ALL_FAMILY_STRIP

local.py goes from 927 -> 378 lines: it now owns only the engine (model
loading, GPU selection, context sizing, chat completion) and the thin
delegations that keep the historical import surface working (_THINK_RE,
_format_tools_for_prompt, _parse_gemma_tool_args, engine._convert_gemma_tool
_messages, ...). chat_completion / _inject_tools / _parse_response all route
through get_grammar(self.model_family).

Suite green: 1598 tests.
Add NATSHELL_PLAN.md — the durable working-plan / source-of-truth for the
recurring improvement job. Records the current ground truth (R1-1..R1-5 done,
R1-6/R1-7/R1-8 + R2-1..R2-6 remaining), the standing rules (security,
green-before-commit, proportionality), and a changelog. REVIEW_NAT_SHELL.md (the
original two-part analysis) is tracked alongside. No code changes in this commit.
…oordinator

Move the context-overflow / connectivity-failure recovery state machine
out of AgentLoop.handle_user_message into natshell.agent.recovery.

- RecoveryCoordinator owns the per-run 'attempted' latch and the ordered
  ladder (overflow->compact/retry, connectivity->ping/compact, then local
  fallback with preserved-context re-injection).
- Loop delegation: the ~130-line except block is now ~11 lines that call
  RecoveryCoordinator.handle and yield its banner events.
- All collaborators (engine, messages, compact, swap, context manager,
  local-engine loader) are injected, so the state machine is testable in
  isolation.
- _context_recovery_attempted stays as a read-only property; _can_fallback
  and _try_local_fallback now delegate to the coordinator.

User-facing event strings are byte-identical. New: tests/test_recovery.py
(16 tests). Full suite: 1614 green.
…r_message

R1-7 (slim the orchestrator) — first sub-step. Pull three cohesive,
low-coupling blocks out of the 491-line handle_user_message:

- _inject_intent   — planning/analysis mode reminders (pure append)
- _preflight_compaction — pre-inference context-pressure check
- _apply_inference_feedback — budget calibration + proactive compaction

All three already have dedicated test coverage (TestIntent*,
TestProactiveCompaction, budget-scaling tests); behaviour is byte-identical.
Method is now 437 lines. Full suite: 1614 green.
…to step_metrics

Second sub-step of R1-7.  handle_user_message 437 -> 390 lines.

- src/natshell/agent/step_metrics.py (new):
  * build_metrics / build_run_stats (moved verbatim from loop.py;
    re-exported by loop as _build_metrics / _build_run_stats)
  * RunStats carrier for the per-run counters (formerly four locals)
  * StepControl / StepOutcome + handle_degenerate_output /
    handle_token_limit — the two continue/return outcome blocks
  * strip_think_residue — reuses the shared grammar THINK_RE /
    THINK_UNCLOSED_RE (verified byte-identical to the old inline
    re.sub pair, incl. closed, unclosed and mixed cases)
- loop.py: inline outcome blocks replaced by handler calls; event text
  and yield order byte-identical to the old code; message mutation
  (append of the partial response) stays in the loop
- tests/test_step_metrics.py (new): 22 tests pinning the handlers,
  the RunStats carrier and the think-strip equivalence
Final chunk of R1-7.  handle_user_message 390 -> 252 lines (goal ~250),
so R1-7 is now DONE (was 790 lines at the start of the unit).

- src/natshell/agent/tool_dispatch.py (new):
  * dispatch_tool_call — one tool call's lifecycle: normalize -> classify
    -> confirm -> execute -> sudo retry -> guard observe -> budget hint
    -> exchange append; buffers its events and returns stop (the guard's
    observation) as a DispatchOutcome; event order + side effects
    byte-identical to the old inline code
  * step_budget_hint — the pure step-exhaustion suffix, now unit-testable
- loop.py: the 130-line for-tool_call body shrinks to a 17-line call
  site; the guard's stop breaks the batch exactly like the old break
- tests/test_tool_dispatch.py (new): 15 tests (budget hints, execution,
  blocked/declined/confirmed gates, argument repair, duplicate-abort stop)
R1-8.  plan_executor.py 429 -> 75 lines: the model-facing prompt
templates (plan generation, per-step, verify-fix retry) move to
agent/plan_prompts.py with VERIFY_FIX_BUDGET; the pure helpers
(_effective_plan_max_steps, validate_plan, plus the re-exported
_shallow_tree) stay.  plan_executor re-exports all moved names so the
historical import paths (app.py, headless.py, tests) are untouched.
Rendered prompts verified byte-identical against a pre-move snapshot
(all 10 tiers/cases); full suite 1651 green.
Auto-fix applied via `ruff check --fix` (ruff 0.15.20, project config
[tool.ruff] select=["E","F","I","W"]). All 19 CI-gate errors (8 × I001,
11 × F401) are resolved. Manual completion: ruff --fix split the four-line
aliased re-export block at loop.py:52-63 into four single-line imports,
leaving only the first line with `# noqa: E402`; added the same `# noqa`
suppressor to the other three lines to silence the resulting E402 (three
new errors the auto-fix would have introduced).

Removed unused imports:
  - src/natshell/agent/loop.py:        is_analysis_request, is_plan_request, Risk, ToolResult (re-export aliases + local re-imports)
  - src/natshell/agent/step_metrics.py: re
  - src/natshell/agent/sudo_retry.py:  (I001 order)
  - src/natshell/inference/grammars/common.py: typing.Callable
  - src/natshell/inference/grammars/gemma.py:   common.is_bare_tool_json (recovered via common.try_bare_json_recovery path)
  - src/natshell/inference/local.py:    ToolCall, new_tool_call_id (re-export surface)
  - tests/test_step_metrics.py:           re, StepOutcome
  - tests/test_tool_dispatch.py:          pytest, ToolRegistry

Suite: 1651 green. ruff: all checks pass.
…Engine protocol)

Add the streaming seam R2 calls for:
- StreamChunk dataclass + StreamingEngine (runtime_checkable) protocol in
  inference/engine.py — callers feature-detect via isinstance/hasattr.
- LocalEngine.stream_completion: async generator that drains llama-cpp
  stream=True in a worker thread (one to_thread hop), yields raw text
  deltas as StreamChunks, then yields a CompletionResult produced by the
  SAME _parse_response pipeline as the blocking path (tool parsing,
  think-strip, degenerate suppression, ContextOverflowError mapping).
- 7 tests in tests/test_streaming_local.py: chunk order, blocking/stream
  parity, tool-call parse on terminal result, think-residue strip,
  overflow surfacing, protocol conformance (LocalEngine conforms,
  RemoteEngine does not).

TUI/headless token routing is a follow-up; R2-2 (parallel read-only
tool calls) is next per plan.
dispatch_tool_batch groups one response's tool calls into segments:
runs of PARALLEL_SAFE_TOOLS (list_directory, natshell_help, skill,
fetch_url, kiwix_search — verified concurrency-free) execute via
asyncio.gather; mutating / guard-stateful calls keep the historical
one-at-a-time path. Events and exchange order stay the in-batch
concatenation in batch order, identical to the serial loop; a guard
stop still halts the remaining segments exactly as the old loop's
break did (pinned by test_stop_halts_later_segments_in_batch), and the
run continues to the next LLM step so the model sees the CRITICAL
suffix. 12 new tests in tests/test_tool_dispatch.py; suite 1667 green.
The rendered tool-definition block previously re-rendered on every
chat_completion/stream_completion call — byte-identical output, but a
fresh string object each time, which churned the prompt prefix for
nothing and worked against llama.cpp's RAM prompt cache.  It is now
memoized per (family, compact-tier, canonical-tools) in a bounded 32-
entry LRU on the engine instance.  Output is byte-identical to before
(pinned against grammar.render_tools); the cache only removes the
redundant re-render. 6 new tests in tests/test_tool_prefix_cache.py.
Suite 1673 green.
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