perf(tui): cut visible cold start ~57% with lazy agent init - #17190
Conversation
Two targeted fixes on the critical path from `hermes --tui` launch to
`gateway.ready`:
1. **Defer `@hermes/ink` import in memoryMonitor.ts.** The static top-level
import dragged the full ~414KB Ink bundle (React + renderer + all
components/hooks) onto the critical path *before* `gw.start()` could
spawn the Python gateway — serialising ~155ms of Node work in front of
it on every launch. `evictInkCaches` only runs inside the 10-second
tick under heap pressure, so it moves to a lazy dynamic import. First
tick hits the ESM cache because the app entry has long since imported
`@hermes/ink`.
2. **Gate `tools.mcp_tool` import on config in tui_gateway/entry.py.**
Importing the module transitively pulls the MCP SDK + pydantic + httpx
+ jsonschema + starlette formparsers (~200ms). The overwhelming
majority of users have no `mcp_servers` configured, so this runs for
nothing. A cheap `load_config()` check (~25ms) skips the 200ms import
when no servers are declared, with a conservative fallback to the old
behaviour if the config probe itself fails.
## Measurements (macOS Terminal.app, Apple Silicon, n=12)
| Metric | Before (p50) | After (p50) | Δ |
|----------------------------|--------------|-------------|----------|
| Python gateway boot alone | 252–365ms | 105–151ms | −180ms |
| `hermes --tui` banner paint | 686ms | 665ms | −21ms |
| `hermes --tui` → ready | **1843ms** | **1655ms** | **−188ms (−10.2%)** |
| `hermes --tui` → ready p90 | 1932ms | 1778ms | −154ms |
| stdev (ready) | 126ms | 83ms | also more consistent |
## Tests
- `scripts/run_tests.sh tests/tui_gateway/ tests/tools/test_mcp_tool.py`:
195 passed. (The one pre-existing failure in
`test_session_resume_returns_hydrated_messages` reproduces on main —
unrelated, it's a mock-DB kwarg mismatch.)
- `ui-tui` vitest: 430 tests, all pass.
- `npm run type-check` in ui-tui: clean.
## Notes
- Node-side first paint ("banner") didn't move meaningfully because that
latency is dominated by Ink's render pipeline + React mount, not by
which imports load first.
- The win shows up entirely in the time from banner to `gateway.ready`
— exactly where we expected it, since both fixes shorten the Python
gateway's boot path or let it overlap more with Node startup.
- No user-visible behaviour change. Memory monitoring still fires every
10s; MCP still works when `mcp_servers` is configured.
TUI session readiness was still laggy after the gateway-ready fixes. Profiling session.create -> session.info showed the slow phase is background AIAgent construction (~1.1s). A cProfile run of tui_gateway.server::_make_agent showed model_tools/tool discovery importing tools.code_execution_tool, whose module-level EXECUTE_CODE_SCHEMA calls _get_execution_mode(), which imported cli.CLI_CONFIG. That pulled the classic interactive CLI stack (prompt_toolkit/Rich and REPL setup) into every agent startup path, including hermes --tui where it is not used. Replace that with hermes_cli.config.read_raw_config(), which is cached and reads only the raw code_execution section. Existing defaults still apply when the key is absent. Measurements on macOS Terminal.app: - import run_agent: ~466ms -> ~347ms - model_tools import: ~418ms -> ~272ms - _make_agent: ~1452ms -> ~1239ms - session.create -> session.info: ~1167ms -> ~999ms - full hermes --tui ready p50: ~1655ms -> ~1537ms Tests: - scripts/run_tests.sh tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py
|
Follow-up commit pushed: Additional profiling found the remaining perceived lag was mostly session readiness after
Fix: read New measurements on macOS Terminal.app:
Cumulative PR improvement vs original baseline is now roughly:
Tests: |
Match classic CLI perceived startup behavior: show the TUI shell and composer before constructing the full AIAgent. session.create now returns a lightweight placeholder session with lazy=true and no longer starts _make_agent eagerly. The first method that needs the agent triggers _start_agent_build() via _sess(); prompt.submit is routed through the RPC worker pool so that the initial wait for agent construction does not block the stdio dispatcher. The intro panel renders skeleton rows for tools/skills while the real session.info payload is absent, then hydrates to the real tools/skills panel once AIAgent initialization completes. Also skip the startup /voice status probe and avoid the input.detect_drop RPC for ordinary plain-text prompts to keep early startup/first-submit paths cheap. Measurements on macOS Terminal.app: - Previous full ready p50 after earlier PR commits: ~1537ms - Lazy skeleton panel p50: ~794ms - Original baseline full ready p50: ~1843ms So the visible startup surface is now ~743ms faster than the prior PR state and ~1.05s faster than the original baseline. First prompt still pays the same agent construction cost if it races the background/skeleton state, matching classic CLI's deferred behavior. Tests: - python -m py_compile tui_gateway/server.py - cd ui-tui && npm run type-check && npm run build - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
|
Follow-up pushed: This implements the lazy/skeleton approach:
Numbers on macOS Terminal.app:
Compared to the original baseline (~1843ms full ready), the visible startup surface is now about ~1.05s faster. First prompt still pays agent construction if it races readiness, which matches classic CLI's deferred behavior. Tests run:
|
hermes --tui cold startThere was a problem hiding this comment.
Pull request overview
Improves perceived cold-start performance of hermes --tui by deferring expensive imports and shifting full AIAgent construction off the initial “first paint” path, while keeping the UI usable via a skeleton session panel until session.info hydrates.
Changes:
- Lazily initialize the backend agent in the TUI gateway (fast
session.create, build on first agent-backed RPC) and treatprompt.submitas a long handler. - Reduce cold-start work by deferring heavy imports (Ink cache eviction helper in Node; MCP discovery in Python when unconfigured; avoid importing classic CLI during tool schema build).
- Update TUI UI to render a tools/skills skeleton while backend session info is still “lazy”, and skip certain startup RPCs on common paths.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ui-tui/src/types.ts | Adds SessionInfo.lazy flag to support skeleton/hydration flow. |
| ui-tui/src/lib/memoryMonitor.ts | Defers @hermes/ink import via dynamic import to keep cold-start path lighter. |
| ui-tui/src/components/branding.tsx | Renders skeleton tool/skill rows when info.lazy is true. |
| ui-tui/src/components/appLayout.tsx | Shows SessionPanel whenever info exists (not only when version exists). |
| ui-tui/src/app/useSubmission.ts | Skips input.detect_drop RPC for common plain-text prompts. |
| ui-tui/src/app/useConfigSync.ts | Avoids startup /voice status RPC; initializes voice UI bit via env. |
| tui_gateway/server.py | Implements lazy agent build (_start_agent_build), triggers build from _sess(), and marks prompt.submit as a long handler. |
| tui_gateway/entry.py | Avoids importing MCP tooling when mcp_servers are not configured. |
| tools/code_execution_tool.py | Uses hermes_cli.config.read_raw_config() to avoid importing interactive CLI during schema construction. |
| tests/tools/test_code_execution.py | Adds/adjusts tests around _load_config() behavior and avoiding CLI dependency. |
Comments suppressed due to low confidence (1)
tui_gateway/server.py:150
- Adding
prompt.submitto_LONG_HANDLERSavoids blocking the dispatcher while the lazy agent build runs, but other “control-plane” methods (e.g.session.interruptat ~2130) still call_sess()and can now block on_wait_agent()for up to 30s during first-time agent init. That reintroduces the stdin backpressure problem described above (interrupts/approvals sitting unread) in the exact window users are most likely to mash interrupt. Consider either addingsession.interrupt(and any other methods that can be invoked during init) to_LONG_HANDLERS, or special-casing_sess()/session.interruptto avoid waiting whenagent_readyisn’t set yet (e.g., return a fast “initializing” response or set an interrupt-pending flag).
_LONG_HANDLERS = frozenset(
{
"cli.exec",
"prompt.submit",
"session.branch",
"session.resume",
"shell.exec",
"skills.manage",
"slash.exec",
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The lazy startup panel could remain stuck on the placeholder when no first prompt was submitted because agent construction only started from _sess(). Keep session.create cheap, but schedule _start_agent_build shortly after returning the placeholder so tools/skills hydrate automatically. Also replace the ugly placeholder bar rows with compact unicode-animations braille loaders for the tools and skills sections. Tests: - python -m py_compile tui_gateway/server.py - cd ui-tui && npm run type-check && npm run build - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py
|
Fix pushed: Addresses the manual QA finding:
Verification:
|
Copilot correctly flagged two concurrency windows: - memoryMonitor could re-enter while awaiting the lazy @hermes/ink import or heap dump, producing duplicate imports/dumps under sustained pressure. - _start_agent_build used a check-then-set guard without synchronization, so concurrent agent-backed RPCs could start duplicate agent builders. Fix both with single-flight guards: cache the dynamic import promise and track per-level dump in-flight state in memoryMonitor, and protect the TUI agent build flag with a per-session lock. Tests: - python -m py_compile tui_gateway/server.py - cd ui-tui && npm run type-check && npm run build - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py
|
Addressed Copilot review in Fixes:
Verification:
|
There was a problem hiding this comment.
Pull request overview
Improves hermes --tui cold-start responsiveness by deferring expensive imports and lazy-initializing the backend agent so the TUI can render a usable surface sooner, then hydrate tools/skills asynchronously.
Changes:
- Add a “lazy session” mode where
session.createreturns immediately and agent construction runs deferred/on-demand. - Reduce cold-start work by deferring heavy imports (Ink bundle, MCP SDK) and avoiding classic-CLI imports during tool discovery.
- Update TUI UI/UX to show loader/skeleton states and skip non-essential startup RPCs.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| ui-tui/src/types.ts | Adds SessionInfo.lazy to support lazy session hydration in the TUI. |
| ui-tui/src/lib/memoryMonitor.ts | Defers @hermes/ink import until memory-pressure handling needs it. |
| ui-tui/src/components/branding.tsx | Renders an inline spinner while tools/skills are still “lazy”/loading. |
| ui-tui/src/components/appLayout.tsx | Shows the session panel when info exists (not only when version is present). |
| ui-tui/src/app/useSubmission.ts | Avoids an extra input.detect_drop RPC for ordinary plain-text prompts. |
| ui-tui/src/app/useConfigSync.ts | Removes startup /voice status probe; initializes UI state from env. |
| tui_gateway/server.py | Implements deferred agent build, placeholder session.create, and long-handler treatment for prompt.submit. |
| tui_gateway/entry.py | Skips MCP discovery import unless MCP servers appear configured. |
| tools/code_execution_tool.py | Uses read_raw_config() instead of importing classic CLI config during schema/tool discovery. |
| tests/tools/test_code_execution.py | Adds/adjusts tests validating _load_config() behavior with read_raw_config(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
A cleanup review found that adding prompt.submit to _LONG_HANDLERS made the RPC pool own the full first-turn wait even though the handler itself already spawns a turn thread. Keep prompt.submit inline and make it return immediately: - look up the session without waiting - kick the lazy agent build - spawn a short waiter thread that blocks on agent_ready, then starts the existing turn dispatcher This keeps stdin dispatch responsive, avoids occupying a bounded pool worker for a normal chat turn, and preserves the lazy-start hydration behavior. Tests: - python -m py_compile tui_gateway/server.py - cd ui-tui && npm run type-check && npm run build - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
|
Clean pass pushed: I re-read the diff with the /clean instruction in mind and tightened the one awkward async shape the independent review called out:
Verification:
|
Clean up the remaining review nits: - let the deferred @hermes/ink import retry after a transient failure instead of memoizing a rejected promise forever - keep memory-monitor in-flight state inside a finally so future exceptions cannot suppress that memory level indefinitely - use read_raw_config for the TUI MCP cold-start probe instead of full load_config() - keep input.detect_drop for explicit relative path prefixes (./ and ../) while preserving the no-RPC fast path for ordinary plain prompts Tests: - python -m py_compile tui_gateway/server.py tui_gateway/entry.py - cd ui-tui && npm run type-check && npm run build - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
|
Final /clean follow-up pushed: Addressed remaining non-blocking review nits:
Verification:
|
There was a problem hiding this comment.
Pull request overview
This PR reduces hermes --tui cold-start latency by deferring expensive imports and moving full AIAgent construction off the initial “paint the TUI” critical path, while still hydrating tools/skills shortly after startup.
Changes:
- Add lazy-session semantics (
info.lazy) and render a loader/skeleton panel untilsession.infohydrates. - Defer heavyweight imports (Ink bundle eviction helpers, MCP SDK discovery) until actually needed/configured.
- Avoid extra startup RPCs (voice status probe, file-drop detection for plain prompts) to keep the stdio RPC pipe responsive.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| ui-tui/src/types.ts | Adds SessionInfo.lazy to represent placeholder/lazy-hydrated session state. |
| ui-tui/src/lib/memoryMonitor.ts | Switches @hermes/ink import to a dynamic import inside the memory-pressure tick. |
| ui-tui/src/components/branding.tsx | Adds inline loader UI for tools/skills while info.lazy is true. |
| ui-tui/src/components/appLayout.tsx | Shows SessionPanel whenever info exists (enables early skeleton rendering). |
| ui-tui/src/app/useSubmission.ts | Skips input.detect_drop RPC for common plain-text prompts. |
| ui-tui/src/app/useConfigSync.ts | Avoids startup voice.toggle status probe; initializes voice bit from env. |
| tui_gateway/server.py | Implements lazy agent build + immediate session.create response; makes prompt.submit non-blocking. |
| tui_gateway/entry.py | Avoids importing MCP tooling unless mcp_servers are configured. |
| tools/code_execution_tool.py | Reads code_execution config via read_raw_config() to avoid importing classic CLI at module import time. |
| tests/tools/test_code_execution.py | Adds/adjusts tests for _load_config behavior with read_raw_config. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Respond to Copilot's lazy-start review: session metadata/history/usage do not need a constructed AIAgent, so keep them on the no-wait session path. This preserves the deferred startup model and avoids blocking simple session RPCs on agent initialization. Tests: - python -m py_compile tui_gateway/server.py tui_gateway/entry.py - cd ui-tui && npm run type-check && npm run build - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
Finish the Copilot review cleanup for lazy prompt submission: - prompt.submit now claims session.running before returning success, preserving the existing RPC-level session busy error so the frontend can queue. - agent-init timeout/failure now emits a normal error event instead of writing a second JSON-RPC response for an already-settled request id. Tests: - python -m py_compile tui_gateway/server.py tui_gateway/entry.py - cd ui-tui && npm run type-check && npm run build - scripts/run_tests.sh tests/tui_gateway/test_protocol.py::test_sess_found tests/tools/test_code_execution_modes.py tests/tools/test_code_execution.py - cd ui-tui && npm test -- --run src/__tests__/useSessionLifecycle.test.ts src/__tests__/useConfigSync.test.ts
|
All Copilot review threads are resolved. Additional fixes pushed while resolving:
Previously pushed fixes covered the other Copilot threads:
Verification rerun after the last fix:
|
There was a problem hiding this comment.
Pull request overview
Reduces hermes --tui cold-start latency by deferring expensive agent/tooling initialization and avoiding heavyweight imports on the critical startup path, while still hydrating the full tools/skills panel shortly after the UI becomes usable.
Changes:
- Introduces “lazy session” startup:
session.createreturns immediately with placeholderSessionInfoand triggers deferredAIAgentconstruction/hydration via background build. - Removes/defers expensive imports on the startup path (lazy
@hermes/inkimport; conditional MCP tooling import; avoid importing classic CLI during tool discovery). - Skips some non-essential early RPCs to keep the single stdio JSON-RPC pipe unblocked (voice status probe; file-drop detection for plain prompts), and updates the UI to show loaders/skeleton state.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui-tui/src/types.ts | Adds SessionInfo.lazy flag for skeleton/hydration state. |
| ui-tui/src/lib/memoryMonitor.ts | Defers @hermes/ink import and adds in-flight guarding around heap-dump actions. |
| ui-tui/src/components/branding.tsx | Shows inline loader for tools/skills while info.lazy and empty. |
| ui-tui/src/components/appLayout.tsx | Renders SessionPanel earlier (no longer gated on info.version). |
| ui-tui/src/app/useSubmission.ts | Avoids input.detect_drop RPC for typical plain-text prompts. |
| ui-tui/src/app/useConfigSync.ts | Removes startup voice.toggle status probe; initializes UI from HERMES_VOICE. |
| tui_gateway/server.py | Implements deferred agent build (_start_agent_build), lazy session.create, and async prompt.submit wait/build behavior. |
| tui_gateway/entry.py | Avoids MCP SDK import unless mcp_servers are configured. |
| tools/code_execution_tool.py | Reads code_execution config via read_raw_config() to avoid importing classic CLI. |
| tests/tools/test_code_execution.py | Adds tests validating _load_config() behavior and ensuring classic CLI is not imported. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Copilot caught that clearing inFlight on a transient normal-memory tick could allow a second dump/eviction to start before the first async tick completed. Only clear dumped on normal; let the in-flight tick's finally remove its own level. Tests: - cd ui-tui && npm run type-check && npm run build
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
…tart-profiling perf(tui): cut visible cold start ~57% with lazy agent init
Summary
This PR makes
hermes --tuifeel much closer to plainhermeson cold start.The main finding from profiling: plain
hermesfeels instant because it shows the prompt before constructingAIAgent. The TUI was doing more work up front: Node/Ink imports, Python gateway imports, MCP discovery, tool discovery, and eager full agent construction for the tools/skills panel.This PR attacks both classes of delay:
AIAgentconstruction.Headline numbers
Measured on macOS Terminal.app / Apple Silicon using a PTY harness, with warmups and repeated measured runs.
gateway.ready_make_agent()The new UX target is skeleton-ready rather than full tools/skills panel ready. The full panel hydrates later when
session.infoarrives.How the numbers were measured
PTY startup harness
I measured the user-visible TUI with a Python harness that:
pty.openpty()so Ink behaves like an actual terminal,Markers:
first_outbannersummoning…full ready31 tools, skills count)skeleton readybrowser:,terminal:,apple:placeholder rows)Each benchmark does warmup runs first, then reports p50/mean/min/max from repeated measured runs.
Backend RPC harness
For backend-only timings, I spawned the Python TUI gateway directly:
Then sent newline-delimited JSON-RPC over stdio and measured:
gateway.readysession.createrequest → responsesession.create→session.infoprompt.submit→ firstsession.info/message.startwhen lazy init is triggeredKey observation:
CPU/import profiling
For CPU/import attribution, I used:
and targeted
cProfilearound:This identified the biggest remaining costs in agent construction and the avoidable classic-CLI import from
tools/code_execution_tool.py.What profiling found
1. Static
@hermes/inkimport blocked gateway startui-tui/src/lib/memoryMonitor.tsstatically imported@hermes/inkjust to accessevictInkCaches.That pulled the full ~414KB Ink bundle — React, renderer, components, hooks — before
gw.start()could spawn Python.Fix: lazy dynamic import inside the memory-pressure tick. That tick only fires after startup and only needs the function under memory pressure.
2. MCP SDK imported even with no MCP servers
tui_gateway/entry.pyunconditionally importedtools.mcp_toolbeforegateway.ready.That transitively loaded:
Cost was about ~200ms on this machine, wasted when
mcp_serversis absent.Fix: cheap config probe first; only import MCP tooling when servers are configured.
3.
execute_codeschema imported the classic CLIDuring tool discovery:
That imported classic
cli.py, prompt_toolkit/Rich/classic-REPL setup, etc. into TUI startup.Fix: read
code_executionconfig using cachedhermes_cli.config.read_raw_config()instead.Measured impact:
import run_agentmodel_toolsimport_make_agent()4. Biggest architectural mismatch: TUI eagerly built
AIAgentClassic CLI:
Old TUI:
So TUI visibly paid the agent construction cost that classic CLI hides until first prompt.
Fix:
session.createnow returns a lightweight lazy session immediately, and the frontend renders skeleton rows. The fullAIAgentis constructed by_start_agent_build()only when an agent-backed method is first called.User-visible behavior after this PR
Startup now shows compact
unicode-animationsbraille loaders instead of fake skeleton rows or literal loading copy:When the real
session.infoevent arrives, this hydrates automatically into the full tools/skills/session panel — no first prompt required.First prompt can still pay agent construction if submitted before hydration completes. That is intentional and matches classic CLI’s deferred-cost model.
Implementation details
ui-tui/src/lib/memoryMonitor.ts@hermes/inkforevictInkCaches.tui_gateway/entry.pymcp_serversare configured.tools/code_execution_tool.pycli.CLI_CONFIGwhile building module-level schema.tui_gateway/server.pysession.createreturns placeholder{ tools: {}, skills: {}, lazy: true }immediately._start_agent_build()centralizes real agent construction._sess()triggers lazy build for agent-backed methods.prompt.submitis treated as a long RPC handler so first-build wait does not block the stdio dispatcher.ui-tui/src/components/branding.tsxinfo.lazyis true.ui-tui/src/app/useConfigSync.ts/voice statusprobe; usesHERMES_VOICEenv for initial UI bit.ui-tui/src/app/useSubmission.tsinput.detect_dropRPC for ordinary plain-text prompts.Tests / verification
Ran:
Results:
Earlier in the PR:
Known unrelated note:
test_session_resume_returns_hydrated_messageshas a pre-existing mock DB kwarg mismatch reproduced outside this change path.