Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2b293c55 | ||
|
|
e75f5d4ecb | ||
|
|
a1cc725a40 | ||
|
|
8aefe25dac | ||
|
|
f12e86fd9d | ||
|
|
a5965c5de2 | ||
|
|
7ba488a785 | ||
|
|
8c0f432bf9 | ||
|
|
2c0151d37a | ||
|
|
18e5c08014 | ||
|
|
c716fdf057 | ||
|
|
d0754a0f27 | ||
|
|
72ed747e48 | ||
|
|
989d15bdf0 | ||
|
|
daf6b2365c | ||
|
|
e8179d5850 | ||
|
|
21ce9222b2 | ||
|
|
29dd8e7a3b | ||
|
|
b158e77637 | ||
|
|
bf7d14815b | ||
|
|
46dbf29eba | ||
|
|
b638e33f03 | ||
|
|
f2fbcbaa70 | ||
|
|
c7932001b7 | ||
|
|
2bae4471df | ||
|
|
c463e6b3c1 | ||
|
|
ce6df5ad01 | ||
|
|
cf8c81ae5f | ||
|
|
eb3a38b5c9 | ||
|
|
6328a57d67 | ||
|
|
f07e8db9ef | ||
|
|
ba8fd85d6a | ||
|
|
9fe3f1044d | ||
|
|
b8515a0c3a | ||
|
|
76ea0bad7b | ||
|
|
0e252b21ec | ||
|
|
526d5868d1 | ||
|
|
e0df83ab2e | ||
|
|
beb8be9766 | ||
|
|
9ed597f532 | ||
|
|
9cd2b9ee8f | ||
|
|
429a5eb249 | ||
|
|
e79f74a50e | ||
|
|
95f58ae040 | ||
|
|
c8effe5239 | ||
|
|
bb8e5421ce | ||
|
|
c5b69ac9e2 | ||
|
|
3006e130de | ||
|
|
bc0878ccaa | ||
|
|
9a29035edd |
@@ -50,3 +50,6 @@ output/
|
||||
temp/
|
||||
|
||||
experiments/
|
||||
|
||||
# Scraps local notes
|
||||
.scraps/
|
||||
|
||||
+279
-20
@@ -2,26 +2,181 @@
|
||||
|
||||
This page explains advanced JSON-based parameter settings for the Simple nodes.
|
||||
|
||||
Use these settings from a JSON file selected by the Simple node `config_path`.
|
||||
Use these settings from a JSON file selected by the Simple node `config_path`;
|
||||
this lets you override Simple-node defaults without editing
|
||||
`config/simple_defaults.json` directly.
|
||||
|
||||
## Supported Advanced Generation Settings
|
||||
## Qwen3.8 Reasoning Effort
|
||||
|
||||
Candidate advanced parameters are listed in the following sample file:
|
||||
Qwen3.8 supports `reasoning_effort` only in Simple-node JSON configuration:
|
||||
|
||||
- `config/simple_advanced.example.json`
|
||||
```json
|
||||
{
|
||||
"qwen3.8": {
|
||||
"enable_thinking": true,
|
||||
"reasoning_effort": "xhigh"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Of those candidates, the only keys currently supported are `seed` in the
|
||||
`advanced_generation_kwargs` section and `seed` in the
|
||||
`advanced_summary_generation_kwargs` section.
|
||||
Supported values are `xhigh`, `medium`, and `low`. The node default is
|
||||
`medium`. Invalid values produce a warning and fall back to `medium`.
|
||||
|
||||
The node converts `low` and `xhigh` into Qwen3.8 system instructions before
|
||||
prompt construction; it does not pass `reasoning_effort` to
|
||||
`llama-cpp-python`. `medium` adds no instruction. The setting is inactive when
|
||||
`enable_thinking` is false, is not available in Full-node UI, and does not
|
||||
inherit from the `qwen3.5` JSON entry.
|
||||
|
||||
## Vision Image Resolution
|
||||
|
||||
Simple-node JSON can raise how much image detail reaches a Vision model:
|
||||
|
||||
```json
|
||||
{
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 12345
|
||||
"image_max_pixels": 1048576
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `advanced_generation_kwargs.image_max_pixels`: `LLM Session Chat (Simple)`
|
||||
downscales IMAGE inputs larger than this pixel count before sending them to
|
||||
the model. The default is `262144` (about 512x512). Values are clamped to
|
||||
`65536`-`4194304`; invalid values produce a warning and use the default. The
|
||||
node applies this value itself and does not pass it to `llama-cpp-python`.
|
||||
|
||||
`1048576` (about 1024x1024) is the recommended value for Gemma 4 and Qwen3.x
|
||||
and is used in `config/simple_advanced.example.json`. The built-in default stays
|
||||
`262144` to avoid higher image token counts for other model families and on CPU
|
||||
setups. The chat handler also applies a fixed per-image token range:
|
||||
|
||||
| Model family | Fixed handler setting | Approx. pixels |
|
||||
| --- | --- | --- |
|
||||
| Gemma 4 | `image_max_tokens: 512` | up to about 1,180,000 |
|
||||
| Qwen3-VL / Qwen3.5 / Qwen3.6 / Qwen3.8 | `image_min_tokens: 1024` | from about 1,050,000 |
|
||||
|
||||
- Gemma 4 images above about 1,180,000 pixels are reduced to 512 tokens by the
|
||||
backend, so larger `image_max_pixels` values add no detail. The limit is fixed
|
||||
because llama.cpp aborts the process when one Gemma 4 image exceeds the
|
||||
default 512-token micro-batch. In local testing, one square image used 121
|
||||
tokens at 512x512 and 441 tokens at 1024x1024; a non-square image used 399
|
||||
tokens at `1048576`.
|
||||
- Qwen images below the minimum are upscaled by the backend, so values below
|
||||
about `1048576` lose detail without reducing image tokens. In local testing
|
||||
with Qwen3.6, one square image used 1024 tokens at both the default and
|
||||
`1048576`.
|
||||
|
||||
More image tokens increase prompt processing time and context usage.
|
||||
|
||||
## Backend Batch Size
|
||||
|
||||
Simple-node JSON can set the llama.cpp prompt-processing batch sizes that
|
||||
`llama-cpp-python` receives when the model is loaded:
|
||||
|
||||
```json
|
||||
{
|
||||
"n_ctx": 8192,
|
||||
"advanced_backend_kwargs": {
|
||||
"n_batch": 2048,
|
||||
"n_ubatch": 2048
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `advanced_backend_kwargs.n_batch`: logical batch size, the most tokens
|
||||
submitted to the backend in one decode call.
|
||||
- `advanced_backend_kwargs.n_ubatch`: physical batch size, the most tokens the
|
||||
backend computes in one pass. Raising it reduces the number of passes needed
|
||||
to process a long prompt, which is the setting that usually affects prompt
|
||||
processing speed on GPU backends such as SYCL.
|
||||
|
||||
Both apply to `LLM Session Chat (Simple)` and `LLM Dialogue Cycle (Simple)`; in
|
||||
Dialogue Cycle the same values are used for model A and model B. Full nodes do
|
||||
not expose them.
|
||||
|
||||
Missing or `null` values are omitted, so the installed backend keeps its own
|
||||
defaults (`n_batch: 2048` and `n_ubatch: 512` in the `llama-cpp-python` build
|
||||
used for testing). Values must be JSON integers; other values are ignored with a
|
||||
warning. Values below `512` are raised to `512`, because llama.cpp aborts the
|
||||
process when one Gemma 4 image exceeds `n_ubatch`.
|
||||
|
||||
`llama-cpp-python` limits `n_batch` to `n_ctx` and `n_ubatch` to `n_batch`, so
|
||||
`n_ubatch` only takes effect up to the smaller of the two. Set `n_batch` and
|
||||
`n_ctx` at least as large as `n_ubatch`; the node prints a warning when the
|
||||
configured `n_ubatch` will be limited.
|
||||
|
||||
A larger `n_ubatch` increases backend compute-buffer memory. Changing either
|
||||
value reloads the model on the next run.
|
||||
|
||||
## Backend Log Verbosity and `logits_all`
|
||||
|
||||
Two more model-load settings are available in `advanced_backend_kwargs` for
|
||||
both Simple nodes:
|
||||
|
||||
```json
|
||||
{
|
||||
"suppress_backend_logs": false,
|
||||
"advanced_backend_kwargs": {
|
||||
"verbosity": 3,
|
||||
"logits_all": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `advanced_backend_kwargs.verbosity`: native llama.cpp log level as an integer
|
||||
from `0` to `5` (`0` output only, `1` error, `2` warning, `3` info, `4`
|
||||
trace, `5` debug). When it is missing or `null`, the node keeps its previous
|
||||
error-only logging. `3` is the usual llama.cpp log level and is enough to
|
||||
check device and layer-offload information. Model-load logs are always
|
||||
printed; `suppress_backend_logs: true` can still hide logs written during
|
||||
generation, so set it to `false` when you need those.
|
||||
- `advanced_backend_kwargs.logits_all`: `true` or `false`. When it is missing
|
||||
or `null`, the node keeps its previous behavior: `true` for Vision loads and
|
||||
the backend default (`false`) for text-only loads. An explicit value applies
|
||||
to both. `false` avoids keeping and copying logits for every prompt token,
|
||||
which lowers memory use and can speed up prompt processing. Vision with
|
||||
`logits_all: false` depends on the backend and model family, so verify it
|
||||
with your model before relying on it.
|
||||
|
||||
Invalid values are ignored with a warning. `verbosity` is specific to the
|
||||
JamePeng `llama-cpp-python` build; if the installed backend rejects it, the
|
||||
node prints a warning and loads the model without it. Changing either value
|
||||
reloads the model on the next run.
|
||||
|
||||
## Supported Advanced Generation Settings
|
||||
|
||||
Advanced parameters are listed in the following sample file:
|
||||
|
||||
- `config/simple_advanced.example.json`
|
||||
|
||||
Simple nodes support `seed`, `top_k`, `min_p`, and `present_penalty` in the
|
||||
`advanced_generation_kwargs` section. The
|
||||
`advanced_summary_generation_kwargs` section supports `seed` only. Full nodes
|
||||
do not expose these settings in the UI.
|
||||
|
||||
```json
|
||||
{
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 12345,
|
||||
"top_k": 40,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Missing or `null` values are omitted, so the installed backend supplies its
|
||||
own defaults. The values shown above match the current JamePeng backend defaults
|
||||
for the three sampling controls; the node does not inject them when they are
|
||||
absent. Valid values are:
|
||||
|
||||
- `top_k`: integer greater than or equal to `0`
|
||||
- `min_p`: number from `0.0` to `1.0`
|
||||
- `present_penalty`: number from `0.0` to `2.0`
|
||||
|
||||
Invalid values are omitted with a warning unless `log_level` is `minimal`.
|
||||
|
||||
```json
|
||||
{
|
||||
"advanced_summary_generation_kwargs": {
|
||||
@@ -30,9 +185,13 @@ Of those candidates, the only keys currently supported are `seed` in the
|
||||
}
|
||||
```
|
||||
|
||||
Note: `tensor_split` is not configured through `advanced_backend_kwargs` yet.
|
||||
It remains a root-level Simple-node JSON config key for backward compatibility;
|
||||
see [PARAMETERS.md](PARAMETERS.md).
|
||||
|
||||
### Why `seed` Is an Advanced Parameter
|
||||
|
||||
When the same model, prompt, image input, generation settings, session state,
|
||||
When the same model, prompt, media input, generation settings, session state,
|
||||
runtime cache behavior, and backend behavior all match, a fixed seed can improve
|
||||
the repeatability of stochastic sampling even when `temperature` is greater
|
||||
than `0`.
|
||||
@@ -42,11 +201,87 @@ output vary, and a fixed seed may not provide the repeatability users expect.
|
||||
For that reason, `seed` is treated as an advanced parameter to try carefully
|
||||
rather than as a general parameter.
|
||||
|
||||
### Advanced Parameters Other Than `seed`
|
||||
### Model Recommendations and Node Parameter Names
|
||||
|
||||
Unsupported parameters other than `seed` that are listed in
|
||||
`config/simple_advanced.example.json` are currently ignored. When `log_level` is
|
||||
not `minimal`, the node prints a warning for those unsupported keys.
|
||||
By default, the node does not replace sampling settings when the model family or
|
||||
thinking mode changes. Apply a recommendation either by setting the
|
||||
corresponding values explicitly or by enabling the model-specific official
|
||||
sampling override described below.
|
||||
|
||||
Qwen3.8 recommends the following settings:
|
||||
|
||||
| Mode | `temperature` | `top_p` | `top_k` | `min_p` | `present_penalty` | `repeat_penalty` |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Thinking | `1.0` | `0.95` | `20` | `0.0` | `0.0` | `1.0` |
|
||||
| Non-thinking | `0.7` | `0.8` | `20` | `0.0` | `1.5` | `1.0` |
|
||||
|
||||
Note: Qwen's official `presence_penalty` corresponds to this node's
|
||||
`present_penalty`, and the official `repetition_penalty` corresponds to this
|
||||
node's `repeat_penalty`.
|
||||
|
||||
Gemma 4 recommends `temperature: 1.0`, `top_p: 0.95`, and `top_k: 64`
|
||||
across its documented use cases; it does not publish a separate setting table
|
||||
for thinking and non-thinking modes.
|
||||
|
||||
The names are mapped into this node as follows:
|
||||
|
||||
- `temperature`, `top_p`, and `repeat_penalty` are root-level Simple settings.
|
||||
- `top_k`, `min_p`, and `present_penalty` are Simple-only advanced generation
|
||||
settings.
|
||||
|
||||
These sampling controls change token selection, not prompt construction. They
|
||||
therefore do not change the node's KV-cache prompt signature.
|
||||
|
||||
### Official Sampling Override
|
||||
|
||||
Simple nodes can apply the documented model recommendations automatically with
|
||||
the model-specific `official_sampling_override` setting. Its default is
|
||||
`false`.
|
||||
|
||||
```json
|
||||
{
|
||||
"qwen3.8": {
|
||||
"enable_thinking": true,
|
||||
"reasoning_effort": "medium",
|
||||
"official_sampling_override": true
|
||||
},
|
||||
"gemma4": {
|
||||
"enable_thinking": false,
|
||||
"official_sampling_override": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, official values take priority over matching root-level settings
|
||||
and `advanced_generation_kwargs`, even when those values were explicitly set.
|
||||
Only parameters included in the documented recommendation are replaced:
|
||||
|
||||
- Qwen3.8 thinking replaces `temperature`, `top_p`, `repeat_penalty`, `top_k`,
|
||||
`min_p`, and `present_penalty` with the thinking row above.
|
||||
- Qwen3.8 non-thinking replaces the same six parameters with the non-thinking
|
||||
row above.
|
||||
- Gemma 4 replaces `temperature`, `top_p`, and `top_k` only. Explicit
|
||||
`repeat_penalty`, `min_p`, and `present_penalty` values remain unchanged
|
||||
because they are not part of the documented Gemma 4 recommendation.
|
||||
|
||||
Qwen3.8 selects its profile from `enable_thinking`; `reasoning_effort` does not
|
||||
select a different sampling profile. Dialogue Cycle resolves the setting for A
|
||||
and B independently, so mixed Qwen3.8 and Gemma 4 cycles use different values
|
||||
for each model. Summary generation is not overridden.
|
||||
|
||||
Saved turn parameters contain the effective sampling values and an
|
||||
`official_sampling_profile` value such as `qwen3.8-thinking`,
|
||||
`qwen3.8-non-thinking`, or `gemma4`. The override does not change the KV-cache
|
||||
prompt signature.
|
||||
|
||||
### Other Advanced Parameters
|
||||
|
||||
Unsupported parameters such as `typical_p`, the Mirostat fields, and the
|
||||
`advanced_backend_kwargs` keys other than `n_batch`, `n_ubatch`, `verbosity`,
|
||||
and `logits_all` that are
|
||||
listed in `config/simple_advanced.example.json` are currently ignored. When
|
||||
`log_level` is not `minimal`, the node prints a warning for those unsupported
|
||||
keys.
|
||||
|
||||
## About `advanced_summary_generation_kwargs`
|
||||
|
||||
@@ -75,7 +310,7 @@ Allowing these parameters to be overridden through
|
||||
For reproducibility tests, try the following settings first.
|
||||
|
||||
In real-machine testing, output changed with `LlamaTrieCache` enabled even when
|
||||
the same seed, prompt, model, and image were used. If you want to check
|
||||
the same seed, prompt, model, and media were used. If you want to check
|
||||
repeatability, `runtime_cache: "off"` is recommended.
|
||||
|
||||
```json
|
||||
@@ -97,7 +332,7 @@ Check the following first:
|
||||
|
||||
- Set `runtime_cache` to `"off"`.
|
||||
- Use `reset_session: true` or a fresh `session_id`.
|
||||
- Use the same model file, mmproj file, image input, prompt, and config.
|
||||
- Use the same model file, mmproj file, media input, prompt, and config.
|
||||
- Make sure history and summary text are not changing the prompt.
|
||||
- If summary reproducibility matters, set
|
||||
`advanced_summary_generation_kwargs.seed`.
|
||||
@@ -106,7 +341,10 @@ Check the following first:
|
||||
|
||||
## History Records
|
||||
|
||||
Applied advanced generation settings are recorded in each saved turn's `params`.
|
||||
Explicit advanced generation settings accepted by node validation are recorded
|
||||
in each saved turn's `params`. Defaults supplied implicitly by the backend are
|
||||
not recorded. If compatibility fallback removes a keyword rejected by an older
|
||||
backend, history still records the explicitly requested value.
|
||||
|
||||
For example:
|
||||
|
||||
@@ -114,7 +352,10 @@ For example:
|
||||
{
|
||||
"params": {
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 12345
|
||||
"seed": 12345,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +373,27 @@ If summary advanced settings are applied, they are also recorded:
|
||||
}
|
||||
```
|
||||
|
||||
Explicit `advanced_backend_kwargs` values are recorded the same way:
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {
|
||||
"advanced_backend_kwargs": {
|
||||
"n_batch": 2048,
|
||||
"n_ubatch": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Not Yet Active
|
||||
|
||||
`config/simple_advanced.example.json` includes experimental fields for future
|
||||
advanced backend or generation settings. At the moment, the only advanced keys
|
||||
that are read are `advanced_generation_kwargs.seed` and
|
||||
`advanced_summary_generation_kwargs.seed`.
|
||||
advanced backend or generation settings. Supported normal-generation keys are
|
||||
`seed`, `top_k`, `min_p`, `present_penalty`, and `image_max_pixels`; summary generation supports
|
||||
`seed` only; backend loading supports `n_batch`, `n_ubatch`, `verbosity`, and
|
||||
`logits_all`. Other advanced keys remain inactive.
|
||||
|
||||
`tensor_split` is an advanced backend-style setting, but it is intentionally
|
||||
kept outside `advanced_backend_kwargs` for now to avoid breaking existing
|
||||
Simple-node JSON configs.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# AGENTS.md
|
||||
|
||||
This is the required entry point for coding agents working on
|
||||
ComfyUI-LLM-Session. It routes agents to the repository's canonical guidance;
|
||||
detailed policy belongs in `docs/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
ComfyUI-LLM-Session is a ComfyUI custom node project for local LLM session
|
||||
workflows. Keep changes scoped, preserve established service and module
|
||||
boundaries, and use pytest as the automated guardrail. Manual ComfyUI checks
|
||||
remain the final behavior validation for UI and runtime workflows.
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `llm_session_nodes.py`: ComfyUI node definitions and compatibility-facing
|
||||
behavior.
|
||||
- `core/`: shared runtime, generation, state, logging, and turn helpers.
|
||||
- `services/`: chat-turn, generation, history, state, and node orchestration.
|
||||
- `infra/`: persistence and storage helpers.
|
||||
- `tests/`: pytest coverage for behavior, contracts, and regressions.
|
||||
- `docs/`: canonical project rules, architecture notes, and change guidance.
|
||||
|
||||
## Required Reading and Routing
|
||||
|
||||
Before repository-specific analysis, planning, editing, review, or release
|
||||
work, read `docs/index.md`, then apply every row below that matches the task.
|
||||
Required outputs are cumulative unless a specialized document defines a more
|
||||
specific format.
|
||||
|
||||
| Task type | Required document | Required output |
|
||||
| --- | --- | --- |
|
||||
| Any repository code change | `docs/agent-development-plan.md` | Implementation summary, checks run or skipped, docs impact, and manual validation needs |
|
||||
| Refactoring | `docs/refactoring-rules.md` | Scope, behavior-preservation notes, and focused tests |
|
||||
| Tests or behavior verification | `docs/testing-rules.md` | Focused/full pytest status and reasons for skipped checks |
|
||||
| Logging, exceptions, or silent fallbacks | `docs/logging-guidelines.md` | Error-handling rationale and test status |
|
||||
| Public behavior, parameters, docs, changelog, commit text, or releases | `docs/change-documentation-guidelines.md` | The documentation and final-response outputs required there |
|
||||
| Service boundaries, dependency direction, or module placement | `docs/architecture.md` | Placement rationale and affected modules or services |
|
||||
| Manual ComfyUI validation may be needed | `docs/manual-test-checklist.md` | Relevant checklist sections for a human to run |
|
||||
| Non-trivial verification planning | `docs/templates/verification-plan.md` | A verification plan or concise equivalent |
|
||||
|
||||
Before acting, extract the applicable requirements from the routed documents.
|
||||
For non-trivial work, record them in a short working checklist. Before the
|
||||
final response, verify all required outputs, formats, prohibitions, and skipped-
|
||||
check explanations.
|
||||
|
||||
## Cross-Cutting Rules
|
||||
|
||||
- Prefer existing patterns, helpers, service boundaries, and dependency
|
||||
direction.
|
||||
- Keep edits scoped to the requested behavior; avoid unrelated refactoring.
|
||||
- Preserve public node interfaces unless the task intentionally changes them.
|
||||
- Add or update tests when changed behavior can be expressed with pytest.
|
||||
- Follow `docs/testing-rules.md` for focused and full-suite test decisions, and
|
||||
report every skipped check with its reason.
|
||||
- Determine documentation impact through `docs/index.md` and
|
||||
`docs/change-documentation-guidelines.md`.
|
||||
- When documentation files are added, removed, or change roles, update
|
||||
`docs/index.md` in the same change.
|
||||
- Do not claim manual ComfyUI validation was performed unless a human actually
|
||||
performed it. Identify the relevant sections of
|
||||
`docs/manual-test-checklist.md` when manual validation is needed.
|
||||
- Report remaining limitations and risks clearly.
|
||||
|
||||
Do not copy detailed policy into this file. When a rule changes, update its
|
||||
canonical document in `docs/`; change this file only when routing or a genuinely
|
||||
cross-cutting rule changes.
|
||||
|
||||
## Development Environment
|
||||
|
||||
- Use `C:\ComfyUI\comfyui_venv_sycl` as the Python virtual environment for this
|
||||
repository. Invoke its interpreter directly; do not create another virtual
|
||||
environment unless the user explicitly requests one.
|
||||
- Before importing `llama_cpp` or running pytest, prepend the bundled DLL
|
||||
directories to `PATH` for the current process. In PowerShell 7:
|
||||
|
||||
```powershell
|
||||
$llamaDllDirs = @(
|
||||
'C:\ComfyUI\comfyui_venv_sycl\Lib\site-packages\llama_cpp\lib'
|
||||
'C:\ComfyUI\comfyui_venv_sycl\Lib\site-packages\llama_cpp\bin'
|
||||
'C:\ComfyUI\comfyui_venv_sycl\Library\bin'
|
||||
)
|
||||
$env:PATH = ($llamaDllDirs + $env:PATH) -join [IO.Path]::PathSeparator
|
||||
& 'C:\ComfyUI\comfyui_venv_sycl\Scripts\python.exe' -m pytest
|
||||
```
|
||||
|
||||
This is a process-scoped `PATH` change; it does not modify the system or user
|
||||
environment permanently.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A change is ready to report when:
|
||||
|
||||
- The requested work is complete and scoped.
|
||||
- Applicable automated checks pass, or skipped checks and their reasons are
|
||||
reported.
|
||||
- Documentation impact has been reviewed.
|
||||
- Required human validation is identified and is not reported as completed
|
||||
unless it was actually performed.
|
||||
- All routed-document output requirements are satisfied.
|
||||
- Remaining limitations or risks are stated.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# Benchmarks
|
||||
|
||||
This page contains informal local benchmark results for model runs with
|
||||
ComfyUI-LLM-Session. These numbers are intended as rough guidance only. Model
|
||||
speed depends heavily on hardware, drivers, Python packages, llama.cpp /
|
||||
llama-cpp-python versions, model format, quantization, prompt length, and
|
||||
whether the run includes vision processing.
|
||||
|
||||
The raw console logs are not published here. The table below is a summarized
|
||||
view of the local results. All rows used the same image input and the same text
|
||||
prompt.
|
||||
|
||||
## Test Setup
|
||||
|
||||
### Local Test Environment
|
||||
|
||||
These results were measured on a single local Windows machine.
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| OS | Microsoft Windows 11 Home 10.0.26200, 64-bit |
|
||||
| CPU | Intel Core Ultra 5 225H, 14 cores / 14 threads |
|
||||
| RAM | 32 GB |
|
||||
| GPU offload | Disabled during GGUF model inference (`n_gpu_layers=0`) |
|
||||
|
||||
### Node Settings
|
||||
|
||||
Unless otherwise noted, these runs used the Simple node defaults from
|
||||
`config/simple_defaults.json`:
|
||||
|
||||
| Setting | Value |
|
||||
|---|---:|
|
||||
| `n_ctx` | `4096` |
|
||||
| `n_gpu_layers` | `0` |
|
||||
| `max_tokens` | `512` |
|
||||
| `dynamic_max_tokens` | `true` |
|
||||
| `min_generation_tokens` | `96` |
|
||||
| `safety_margin_tokens` | `64` |
|
||||
| `max_turns` | `6` |
|
||||
| `temperature` | `0.7` |
|
||||
| `top_p` | `0.9` |
|
||||
| `repeat_penalty` | `1.12` |
|
||||
| `repeat_last_n` | `256` |
|
||||
| `runtime_cache` | `LlamaTrieCache` |
|
||||
| `persistent_cache` | `off` |
|
||||
| `summarize_old_history` | `true` |
|
||||
|
||||
Most GGUF rows below logged `token_limit=448`, which is consistent with
|
||||
`max_tokens=512` and `safety_margin_tokens=64`.
|
||||
|
||||
All benchmark runs used image input. Vision-capable GGUF runs used the matching
|
||||
`mmproj` file for the model.
|
||||
|
||||
## Local Speed Results
|
||||
|
||||
Rows are sorted by dense-equivalent size from smallest to largest. Each row used
|
||||
the same image and prompt. `Prompt time` is the full ComfyUI prompt execution
|
||||
time logged for the run. `Generation time` and `tok/s` come from the LLM Session
|
||||
generation metrics where available.
|
||||
|
||||
| Size | Dense-equivalent size | Model | Runtime | Generation time | Tokens | tok/s | Predicted tok/s | Prompt time |
|
||||
|---:|---:|---|---|---:|---:|---:|---:|---:|
|
||||
| 2B | 1.0B | `gemma-4-E2B-it-heretic-Q8_K_XL.gguf` | GGUF, `n_gpu_layers=0` | 40.67 s | 292 | 7.18 | - | 47.58 s |
|
||||
| 1.3B | 1.3B | `MiniCPM-V-4_6-Q8_0.gguf` | GGUF, `n_gpu_layers=0` | 23.43 s | 130 | 5.55 | 5.57 | 37.64 s |
|
||||
| 4B | 1.4B | `gemma-4-E4B-it-qat-UD-Q4_K_XL.gguf` | GGUF, `n_gpu_layers=0` | 81.82 s | 429 | 5.24 | - | 85.22 s |
|
||||
| 4B | 1.5B | `gemma-4-E4B-it-heretic-Q8_K_XL.gguf` | GGUF, `n_gpu_layers=0` | 94.89 s | 448 | 4.72 | - | 100.70 s |
|
||||
| 26B | 3.2B | `gemma-4-26B-A4B-it-qat-q4_0-uncensored-heretic-Q4_0.gguf` | GGUF, `n_gpu_layers=0` | 62.94 s | 141 | 2.24 | - | 132.99 s |
|
||||
| 35B | 6.5B | `Qwen3.6-35B-A3B-uncensored-heretic.i1-Q4_K_M.gguf` | GGUF, `n_gpu_layers=0` | 88.17 s | 99 | 1.12 | - | 99.19 s |
|
||||
| 9B | 9B | `Qwen3.5-9B-ultra-uncensored-heretic-v2-Q8_0.gguf` | GGUF, `n_gpu_layers=0` | 162.88 s | 110 | 0.68 | 0.80 | 169.21 s |
|
||||
| 12B | 12B | `gemma-4-12B-it-qat-q4_0-uncensored-heretic-Q4_0.gguf` | GGUF, `n_gpu_layers=0` | 137.37 s | 127 | 0.92 | 0.60 | 144.75 s |
|
||||
| 27B | 27B | `Qwen3.6-27B-uncensored-heretic-v2-Native-MTP-Preserved-Q5_K_M.gguf` | GGUF, `n_gpu_layers=0` | 461.07 s | 125 | 0.27 | 0.27 | 471.95 s |
|
||||
| 31B | 31B | `gemma-4-31B-it-heretic.Q5_K_M.gguf` | GGUF, `n_gpu_layers=0` | 412.16 s | 126 | 0.31 | 0.23 | 426.83 s |
|
||||
|
||||
## External Reference Result
|
||||
|
||||
The following result was produced by ComfyUI's official Generate Text node, not
|
||||
by a ComfyUI-LLM-Session node. It is kept only as a rough local reference and
|
||||
should not be compared directly with the GGUF rows above.
|
||||
|
||||
| Size | Dense-equivalent size | Model | Runtime | Generation time | Tokens | tok/s | Predicted tok/s | Prompt time |
|
||||
|---:|---:|---|---|---:|---:|---:|---:|---:|
|
||||
| 9B | 9B | `qwen3.5_9b_bf16.safetensors` | Generate Text node, CPU, `torch.float16` | 564 s at 97/512 tokens | 97/512 | 0.17 | 0.80 | 598.70 s |
|
||||
|
||||
## Notes
|
||||
|
||||
- These are single-machine local measurements, not a controlled benchmark
|
||||
suite.
|
||||
- These runs used `reset_session=false`, so each model received a different
|
||||
history context in addition to the shared image input and text prompt. The
|
||||
`Prompt time` for `gemma-4-26B-A4B` also includes summarization. This is not a
|
||||
clean benchmark protocol.
|
||||
- `Predicted tok/s` is calculated only for dense models from the approximation
|
||||
`tok/s = 7.24 / size`, which was derived from the measured dense-model
|
||||
results.
|
||||
- `Dense-equivalent size` is back-calculated from measured tok/s using the
|
||||
dense-model approximation `tok/s = 7.24 / size`. This dataset was not
|
||||
sufficient to establish a reliable formula based on total parameters and
|
||||
active/effective parameters.
|
||||
- The approximation does not account for factors that should ideally be modeled,
|
||||
such as quantization level, so treat these values as rough reference values.
|
||||
- The external `qwen3.5_9b_bf16.safetensors` CPU row comes from progress-bar
|
||||
output rather than the LLM Session generation summary.
|
||||
+168
@@ -4,6 +4,174 @@ All notable changes to ComfyUI-LLM-Session will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added
|
||||
- Added Simple JSON-only `advanced_backend_kwargs.n_batch` and
|
||||
`advanced_backend_kwargs.n_ubatch` for `LLM Session Chat (Simple)` and
|
||||
`LLM Dialogue Cycle (Simple)` to set the llama.cpp batch sizes at model
|
||||
load. Values below 512 are raised to 512, explicit values are recorded in
|
||||
turn history, and changing them reloads the model. See
|
||||
`ADVANCED_PARAMETERS.md`.
|
||||
- Added Simple JSON-only `advanced_backend_kwargs.verbosity` (0-5) for native
|
||||
llama.cpp log verbosity and `advanced_backend_kwargs.logits_all` to override
|
||||
the Vision-load default of `true`. Unset values keep the previous behavior.
|
||||
|
||||
- Compatibility
|
||||
- Retry model loading without `verbosity` when the installed
|
||||
`llama-cpp-python` rejects it.
|
||||
|
||||
- Changed
|
||||
- Unsupported `advanced_backend_kwargs` keys now produce the same ignored-key
|
||||
warning as the other advanced sections when `log_level` is not `minimal`.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-09-30
|
||||
|
||||
- Added
|
||||
- Added Simple JSON-only `advanced_generation_kwargs.image_max_pixels` for
|
||||
`LLM Session Chat (Simple)` to raise the IMAGE downscale limit from the
|
||||
default 262144 pixels.
|
||||
- Added the setting to `config/simple_advanced.example.json` with the
|
||||
recommended value `1048576` for Gemma 4 and Qwen3.x.
|
||||
|
||||
- Compatibility
|
||||
- Gemma 4 Vision now passes a fixed `image_max_tokens: 512` to
|
||||
`Gemma4ChatHandler`, so raising `image_max_pixels` gives Gemma 4 finer
|
||||
detail up to about 1,180,000 pixels. The limit avoids a llama.cpp abort. See
|
||||
`ADVANCED_PARAMETERS.md`.
|
||||
- Retry Vision chat handler initialization without `image_max_tokens` when
|
||||
the installed `llama-cpp-python` handler rejects it.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-09-24
|
||||
|
||||
- Session Chat media Autogrow
|
||||
- Added V3 adapters for `LLM Session Chat` and `LLM Session Chat (Simple)` that expose `media_0` and reveal subsequent IMAGE/AUDIO connectors as links are added.
|
||||
- Aggregate multiple media connectors in numeric order while preserving existing IMAGE-batch and Gemma 4 AUDIO handling.
|
||||
- Migrate saved `image` and `media` workflow links to `media_0` on Autogrow-capable ComfyUI builds.
|
||||
- Preserve and repair the short `media_0` display label when migrated workflows are saved and reloaded.
|
||||
- Retain the legacy single `media` node implementation as a fallback when the ComfyUI V3 Autogrow API is unavailable.
|
||||
- Document that connector names are not sent to the model and that prompts should refer to media by presentation order and type.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-08-17
|
||||
|
||||
- Added
|
||||
- Added Qwen3.8 as an independent canonical model family while continuing to
|
||||
use the compatible `Qwen35ChatHandler`; Qwen3.5 JSON settings do not carry
|
||||
over to Qwen3.8.
|
||||
- Added Simple JSON-only Qwen3.8 `reasoning_effort` support for `xhigh`,
|
||||
`medium`, and `low`, with `medium` as the default and no Full-node UI control.
|
||||
- Added Simple JSON-only `top_k`, `min_p`, and JamePeng-compatible
|
||||
`present_penalty` generation controls, including validation and turn-history
|
||||
recording.
|
||||
- Added opt-in Simple JSON `official_sampling_override` profiles for Qwen3.8
|
||||
thinking/non-thinking modes and Gemma 4, including independent A/B profile
|
||||
resolution in mixed-model Dialogue Cycles and effective-profile history.
|
||||
|
||||
- Compatibility
|
||||
- Routed Qwen3.8 through the existing Qwen3.5 text, Vision, mmproj, and
|
||||
thinking on/off compatibility paths.
|
||||
- Added Qwen3.8 model-name aliases and matching hyphenated Qwen3.5 and Qwen3.6
|
||||
filename aliases for consistent model-family and mmproj discovery.
|
||||
- Applied Qwen3.8 reasoning effort consistently to text, Vision, retry
|
||||
prompts, KV signatures, and saved turn parameters without forwarding it to
|
||||
the backend.
|
||||
- Added targeted fallback for older backends that reject one of the new
|
||||
advanced generation keywords.
|
||||
- Kept Qwen3.8 `preserve_thinking` and video input unsupported. Vision
|
||||
recognition was manually confirmed; the exact minimum backend version and
|
||||
real-runtime KV-cache behavior remain unestablished.
|
||||
|
||||
- Tests
|
||||
- Added regression coverage for Qwen3.8 family detection, handler defaults,
|
||||
mmproj discovery, text prompt construction, summary thinking override,
|
||||
advanced generation validation, sampling-profile resolution, history
|
||||
recording, and compatibility fallback.
|
||||
|
||||
- Documentation
|
||||
- Refined the English and Japanese project introductions and package metadata
|
||||
to clarify in-ComfyUI GGUF execution, multimodal support, supported model
|
||||
families, and model-to-model dialogue use cases.
|
||||
- Clarified that agents must extract applicable requirements from canonical
|
||||
docs before acting, instead of relying on `AGENTS.md` to duplicate every
|
||||
detailed rule.
|
||||
- Clarified release-note guidance so draft GitHub release notes are provided
|
||||
in the final response unless a separate file is explicitly requested.
|
||||
- Standardized commit-message suggestions as one-line summaries covering all
|
||||
changes since the previous commit unless a narrower scope is requested.
|
||||
- Documented Qwen3.8 and Gemma 4 sampling recommendations, parameter-name
|
||||
mappings, backend-default behavior, and the supported advanced settings.
|
||||
- Documented official sampling override precedence, profile selection, and
|
||||
the parameters intentionally preserved for Gemma 4.
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2026-07-05
|
||||
|
||||
- Media input for Session Chat nodes
|
||||
- Renamed the optional `LLM Session Chat` and `LLM Session Chat (Simple)` input from `image` to `media`.
|
||||
- IMAGE tensors and IMAGE batches are accepted; batches are sent as multiple image message parts.
|
||||
- ComfyUI `AUDIO` objects are accepted only for Gemma 4 models, encoded as WAV, and sent as `input_audio` message parts.
|
||||
- Unsupported media inputs, and AUDIO inputs for non-Gemma 4 models, now stop with explicit errors.
|
||||
- Validate media before model loading so unsupported AUDIO/media errors are reported before mmproj or handler loading errors.
|
||||
- Route media validation and message-building failures through the normal node error result path.
|
||||
- Added a frontend workflow migration that maps legacy `image` inputs and links to `media` when old workflows are loaded.
|
||||
- Kept a backend compatibility shim so legacy `image` kwargs are treated as `media` if they still reach execution.
|
||||
|
||||
- Documentation
|
||||
- Added `BENCHMARKS.md` with informal local image-input speed results,
|
||||
including dense-equivalent model sizes and rough tok/s predictions.
|
||||
- Linked the benchmark notes from the README performance notes.
|
||||
- Added `docs/testing-rules.md` and linked it from the documentation index to
|
||||
define repository-wide test placement, fixture, parametrization, and
|
||||
regression-test rules.
|
||||
- Added `AGENTS.md` as the coding-agent entry point for repository rules,
|
||||
required reading, verification expectations, and commit-message guidance.
|
||||
- Clarified agent routing for commit-message suggestions, release preparation,
|
||||
draft GitHub release notes, manual ComfyUI checklist sections, and skipped
|
||||
test reporting.
|
||||
- Added `docs/agent-development-plan.md`,
|
||||
`docs/templates/verification-plan.md`, and
|
||||
`docs/manual-test-checklist.md` to document agent workflow, verification
|
||||
planning, and selective manual ComfyUI validation.
|
||||
|
||||
- Tests
|
||||
- Added direct service-layer tests for `HistoryPersistenceService` and
|
||||
`KvStateService`.
|
||||
- Centralized repeated `llm_session_nodes` import setup in a shared pytest
|
||||
fixture.
|
||||
- Split the large turn-execution service test module into focused service
|
||||
test files with shared test helpers.
|
||||
- Enabled strict pytest configuration checks and converted model-family alias
|
||||
cases to parametrized tests.
|
||||
|
||||
- Runtime diagnostics
|
||||
- Added `log_level: debug` phase timing diagnostics and long-generation heartbeat logs without changing the default `timing` log output.
|
||||
- Changed generation attempt logs to report `token_limit` instead of `max_tokens`, and added completion token / tokens-per-second diagnostics when available.
|
||||
- Kept streamed console responses visually separated from follow-up status logs when the generated text does not end with a newline.
|
||||
- Labeled `LLM Dialogue Cycle` turn logs by role and cycle number, and enabled generation attempt diagnostics for Dialogue Cycle turns.
|
||||
|
||||
- Backend compatibility
|
||||
- Prefer `mmproj_path` when initializing multimodal chat handlers and fall back to the older `clip_model_path` keyword only when the installed handler rejects the new name.
|
||||
- Use `Gemma4ChatHandler` as a Gemma 3 Vision compatibility fallback when PyPI `llama-cpp-python` does not provide `Gemma3ChatHandler`, while preserving the dedicated handler on JamePeng builds.
|
||||
- Retry Vision chat handler initialization without known optional kwargs such as Gemma 4 `enable_thinking` and Qwen2.5-VL `image_min_tokens` when the installed `llama-cpp-python` handler rejects them.
|
||||
- Documented the JamePeng `llama-cpp-python` MTMD projector keyword compatibility path.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.3] - 2026-06-12
|
||||
|
||||
- ComfyUI Registry compatibility
|
||||
- Removed a plain URL comment from `requirements.txt` to avoid false-positive custom URL dependency flagging.
|
||||
- No runtime behavior changes from `1.2.2`.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.2] - 2026-06-12
|
||||
|
||||
- Vision model diagnostics
|
||||
|
||||
+80
-32
@@ -19,13 +19,13 @@ This document summarizes empirical compatibility results obtained during develop
|
||||
- DeepSeek
|
||||
- Gemma 2 Instruct (2B / 9B)
|
||||
- Gemma 3 Instruct (4B / 12B)
|
||||
- Gemma 4 (E2B / E4B /31B / 26B-A4B)*
|
||||
- Gemma 4 (E2B / E4B / 12B / 26B-A4B / 31B)
|
||||
- GLM-4.6V Flash*
|
||||
- gpt-oss
|
||||
- Llama 3.1 Instruct (8B / 70B)
|
||||
- LLaVA
|
||||
- MiniCPM-V 2.6
|
||||
- MiniCPM-V 4.6
|
||||
- MiniCPM-V 4.6*
|
||||
- Mistral NeMo 12B Instruct
|
||||
- Nemotron-Nano*
|
||||
- Phi-3 Mini Instruct
|
||||
@@ -36,14 +36,21 @@ This document summarizes empirical compatibility results obtained during develop
|
||||
- Qwen3-VL (4B / 8B)
|
||||
- Qwen3.5 (9B / 27B / 35B-A3B)*
|
||||
- Qwen3.6 (27B / 35B-A3B)*
|
||||
- Qwen3.8 (27B)*
|
||||
|
||||
**Note:** Entries marked with `*` either do not work on official llama-cpp-python 0.3.16 or have not been tested on it.
|
||||
**Note:** Entries marked with `*` either do not work on PyPI `llama-cpp-python` or have not been tested on it.
|
||||
|
||||
Qwen3.8 is a separate canonical node family because its Simple JSON settings
|
||||
must not inherit Qwen3.5 settings. It still reuses `Qwen35ChatHandler` because
|
||||
the model declares the `qwen3_5` architecture. Qwen3.8 Vision recognition has
|
||||
been manually confirmed; text construction, family routing, and mmproj
|
||||
discovery are covered by automated tests.
|
||||
|
||||
---
|
||||
|
||||
## MoE Models (Backend-Dependent)
|
||||
|
||||
- Qwen3-30B-A3B, Qwen3.5-35B-A3B: confirmed working
|
||||
- Qwen3-30B-A3B, Qwen3.5/3.6-35B-A3B, and Gemma4-26B-A4B: confirmed working
|
||||
- Mixtral 8x7B GGUF: may fail to load depending on backend build
|
||||
|
||||
Mixtral failures occur at model load time and are likely backend-related.
|
||||
@@ -53,22 +60,32 @@ Mixtral failures occur at model load time and are likely backend-related.
|
||||
## Vision Support Caveats
|
||||
|
||||
- Some vision-capable models may ignore image inputs without errors
|
||||
- `LLM Session Chat` and `LLM Session Chat (Simple)` send IMAGE batches as
|
||||
multiple image message parts. Whether every frame/image is used depends on the
|
||||
active backend handler and model.
|
||||
- Vision compatibility depends on mmproj selection and chat template support
|
||||
- `lfm2-vl` / `lfm2.5-vl` handler wiring is present, but end-to-end validation has not been completed in this test environment yet
|
||||
- GGUF discovery and mmproj prefix classification are case-insensitive (`.gguf`/`.GGUF`, `mmproj-`/`MMPROJ-`)
|
||||
- Vision chat handler detection uses dynamic loading. If a required handler is
|
||||
unavailable in your `llama-cpp-python` build, execution stops with a
|
||||
diagnostic error instead of silently falling back to text-only mode.
|
||||
- mmproj auto-detection depends on normalized model-family aliases in model and
|
||||
mmproj filenames. If filenames fall outside the expected alias patterns,
|
||||
Auto-detect may fail.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade-related Compatibility Notes
|
||||
## Gemma 4 AUDIO Input
|
||||
|
||||
The following notes are compatibility-related cautions for existing users upgrading to the `1.0.4` series.
|
||||
ComfyUI `AUDIO` input is currently routed only for models detected as Gemma 4.
|
||||
The node encodes the AUDIO object as WAV and sends it as an OpenAI-style
|
||||
`input_audio` message part. Other model families reject AUDIO explicitly.
|
||||
|
||||
- Vision model chat handler detection now uses dynamic loading. Supported Vision model coverage has increased, but if an image input or explicit mmproj requires a handler that is unavailable in your `llama-cpp-python` build, execution stops with a diagnostic error instead of silently falling back to text-only mode.
|
||||
- mmproj auto-detection now depends on normalized aliases for both model and mmproj filenames. If filenames fall outside the expected prefix patterns, Auto-detect may fail.
|
||||
- Some Vision-family models remain highly backend-dependent. Even when the handler loads successfully, image input may still be unstable or unsupported in practice. In my environment, `Qwen3.5` Vision works with the JamePeng fork `0.3.33+`, but it should still be treated as backend-dependent.
|
||||
- The cache configuration model has changed. The new default settings are `persistent_cache = "off"` and `runtime_cache = "LlamaTrieCache"`. Existing UI settings should be reselected manually if needed. Older JSON config keys are ignored, so update your config files to use the new option names and values. Depending on your `llama-cpp-python` build, `KV_cache` and `LlamaDiskCache` may still be unstable or unavailable, which is why `LlamaTrieCache` is now the default.
|
||||
- Cache compatibility checks are now stricter. Cache data created by older versions or under different runtime conditions may be invalidated automatically. This is expected safety behavior, not a defect.
|
||||
- `reset_session` now clears history and per-session KV state, but keeps the session-scoped disk cache namespace.
|
||||
This is intended for Gemma 4 builds whose chat handler actually consumes audio
|
||||
waveform input, especially the Gemma 4 12B Unified path. Model-card support and
|
||||
GGUF/backend support are separate requirements: use a recent JamePeng
|
||||
`llama-cpp-python` build and verify that its `Gemma4ChatHandler` accepts
|
||||
`input_audio` in your environment. In current testing, the handler still
|
||||
requires a valid mmproj path; leaving mmproj unset fails during handler
|
||||
initialization.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,32 +93,63 @@ The following notes are compatibility-related cautions for existing users upgrad
|
||||
|
||||
**Important:** Model compatibility varies by llama-cpp-python version. Based on my testing environment:
|
||||
|
||||
| Version | confirmed <br> models <br> (Text)| Qwen2.5-VL <br> LLaVA <br> Llama-3.1 <br> MiniCPM-V 2.6 <br> (Vision) | Qwen3-VL/3.5 <br> Gemma 3/4 <br> GLM-4.6V <br> (Vision) |
|
||||
|---------|-------------------|-------------------|-------------------|
|
||||
| 0.3.16 (official) | ✅* | ✅ | ❌ |
|
||||
| 0.3.35+ (JamePeng fork) | ✅ | ✅ | ✅ |
|
||||
| Version | confirmed <br> models <br> (Text)| Qwen2.5-VL <br> LLaVA <br> Llama-3.1 <br> MiniCPM-V 2.6 <br> Gemma 3/4 <br> (Vision) | Qwen3-VL <br> Qwen3.5/3.6 <br> GLM-4.6V <br> MiniCPM-V 4.6 <br> (Vision) | Gemma 4 <br> (Audio)|
|
||||
|---------|-------------------|-------------------|-------------------|-------------------|
|
||||
| 0.3.32+ (PyPI) | ✅* | ✅ | ❌ | ❌ |
|
||||
| 0.3.40+ (JamePeng fork) | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
**Note:** Entries marked with `*` either do not work on official llama-cpp-python 0.3.16 or have not been tested on it.
|
||||
**Note:** Entries marked with `*` either do not work on PyPI `llama-cpp-python` or have not been tested on it.
|
||||
|
||||
Backend behavior differs between PyPI `llama-cpp-python` and JamePeng builds;
|
||||
Vision handler compatibility fallbacks are applied only in narrow cases.
|
||||
|
||||
<details>
|
||||
<summary>Handler Fallback Details</summary>
|
||||
|
||||
**Recommended Backend Notes:**
|
||||
For newer Vision model families, please follow the build and installation information provided by the upstream JamePeng llama-cpp-python project and choose a build appropriate for your OS, Python version, and acceleration backend.
|
||||
|
||||
`0.3.33+` (JamePeng fork) works for `Qwen3.5` Vision in my environment. Earlier `0.3.30+` builds added support for `Qwen3.5`, but Vision mode was not yet working reliably for me at that stage.
|
||||
|
||||
For Qwen3.8, use a current JamePeng build that includes recent llama.cpp
|
||||
Qwen3.5-architecture and MTMD updates. The exact minimum backend version and
|
||||
real-runtime KV-cache behavior have not been established yet.
|
||||
|
||||
Qwen3.8 `reasoning_effort` is supported only through Simple-node JSON config;
|
||||
it is not forwarded to the backend and is not exposed in Full-node UI.
|
||||
`preserve_thinking` and video input remain unsupported.
|
||||
|
||||
Recent JamePeng MTMD chat handlers use `mmproj_path` as the projector argument.
|
||||
Older handlers used `clip_model_path`. The node runtime prefers `mmproj_path`
|
||||
and falls back to `clip_model_path` only when the installed handler rejects the
|
||||
new keyword, so Vision handler loading remains compatible across these backend
|
||||
API stages.
|
||||
|
||||
Some PyPI `llama-cpp-python` handlers accept newer model families while omitting
|
||||
JamePeng-specific optional kwargs. If a Vision chat handler rejects known
|
||||
optional kwargs such as Gemma 4 `enable_thinking` or `image_max_tokens`, or
|
||||
Qwen2.5-VL `image_min_tokens`, the node runtime logs a warning, removes the rejected kwarg,
|
||||
and retries handler initialization.
|
||||
|
||||
Some PyPI `llama-cpp-python` builds do not provide `Gemma3ChatHandler` even
|
||||
though `Gemma4ChatHandler` can accept Gemma 3 image input. In that environment,
|
||||
Gemma 3 Vision loading falls back to `Gemma4ChatHandler`. JamePeng builds that
|
||||
provide a dedicated `Gemma3ChatHandler` continue to use the dedicated handler.
|
||||
|
||||
These fallbacks are intentionally narrow:
|
||||
|
||||
- Model-family detection is preserved. A Gemma 3 model is still treated as
|
||||
`gemma3` even when `Gemma4ChatHandler` is used as the Vision handler.
|
||||
- Dedicated handlers are preferred whenever the installed backend provides them.
|
||||
- Only known optional handler kwargs are removed on retry; unrelated handler
|
||||
initialization errors are still reported.
|
||||
|
||||
**Source:** https://github.com/JamePeng/llama-cpp-python
|
||||
|
||||
⚠️ **Disclaimer:** Your results may differ depending on system configuration, GPU drivers, and other factors. If you encounter issues, please verify your environment setup and consider reporting compatibility details.
|
||||
|
||||
---
|
||||
|
||||
## Chat Template Compatibility (System Role)
|
||||
|
||||
Some models do not support the `system` role.
|
||||
|
||||
The node automatically retries by merging system messages into user messages.
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
These results are environment-dependent and provided for reference only.
|
||||
These results are environment-dependent and provided for reference only. Your
|
||||
results may differ depending on system configuration, GPU drivers, and other
|
||||
factors. If you encounter issues, please verify your environment setup and
|
||||
consider reporting compatibility details.
|
||||
|
||||
@@ -6,6 +6,8 @@ Thank you for your interest in contributing! This document provides guidelines f
|
||||
|
||||
Before proposing larger refactors or behavior changes, please review these documents:
|
||||
|
||||
- [Documentation Index](docs/index.md)
|
||||
- Entry point for current docs, canonical references, and historical records.
|
||||
- [Architecture Overview](docs/architecture.md)
|
||||
- Module layering and dependency direction.
|
||||
- [Refactoring Rules](docs/refactoring-rules.md)
|
||||
|
||||
+153
-18
@@ -71,7 +71,13 @@ Recommended usage:
|
||||
### tensor_split
|
||||
Advanced Simple-node setting for multi-GPU llama.cpp / llama-cpp-python environments.
|
||||
|
||||
This setting is read from `config/simple_defaults.json` only. Leave it as `null` or omit it to use the llama.cpp default behavior.
|
||||
This setting is read from the Simple-node JSON config (`config/simple_defaults.json`
|
||||
or a file selected with `config_path`). Leave it as `null` or omit it to use the
|
||||
llama.cpp default behavior.
|
||||
|
||||
Although `tensor_split` is conceptually an advanced backend setting, it is
|
||||
currently kept as a root-level Simple-node JSON config key for backward
|
||||
compatibility.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -121,36 +127,108 @@ Default:
|
||||
- `false`
|
||||
|
||||
Currently supported formats:
|
||||
- Gemma 4
|
||||
- Qwen3.5 / Qwen3.6 compatibility path
|
||||
- Qwen3.8
|
||||
- Gemma 4
|
||||
- MiniCPM-V 4.6
|
||||
|
||||
Full nodes expose this as an optional UI setting. Simple nodes can override it in `config/simple_defaults.json`:
|
||||
|
||||
```json
|
||||
"gemma4": {
|
||||
"qwen3.5": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"qwen3.5": {
|
||||
"qwen3.8": {
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "medium",
|
||||
"official_sampling_override": false
|
||||
},
|
||||
"gemma4": {
|
||||
"enable_thinking": false,
|
||||
"official_sampling_override": false
|
||||
},
|
||||
"minicpm-v-4.6": {
|
||||
"enable_thinking": false
|
||||
}
|
||||
```
|
||||
|
||||
When disabled, the node asks the supported model/chat handler not to expose thinking output. This behavior still depends on the model, chat handler, and `llama-cpp-python` build, so some models may still emit internal channel text.
|
||||
|
||||
By default, the node does not automatically change sampling settings with
|
||||
thinking mode. For Qwen3.8 and Gemma 4 recommended sampling values and their
|
||||
Simple JSON mapping, see [ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md).
|
||||
|
||||
Simple JSON can opt into automatic model-specific sampling with
|
||||
`official_sampling_override: true`. For Qwen3.8, `enable_thinking` selects the
|
||||
thinking or non-thinking recommendation. The default is `false`.
|
||||
|
||||
For Qwen3.8, `reasoning_effort` accepts `xhigh`, `medium`, or `low` in Simple
|
||||
JSON config. Its node default is `medium`; the setting has no effect when
|
||||
`enable_thinking` is false. Qwen3.5 settings are not used as a fallback for
|
||||
Qwen3.8.
|
||||
|
||||
Gemma 4 note:
|
||||
|
||||
Gemma 4 may emit internal channel delimiters such as:
|
||||
When using Gemma 4 E2B / E4B with an mmproj loaded, the current JamePeng `Gemma4ChatHandler` appears to require thinking mode to be enabled. In that case, disabling thinking may not work as expected.
|
||||
|
||||
```text
|
||||
<|channel>thought
|
||||
...
|
||||
<channel|>
|
||||
final answer...
|
||||
```
|
||||
---
|
||||
|
||||
In this case, the text after the last `<channel|>` appears to be the final answer intended for the user. The node therefore treats the text after the last channel delimiter as the displayed final output.
|
||||
## Media Input
|
||||
|
||||
If `max_tokens` is too small, generation may stop before Gemma 4 emits `<channel|>` and the final answer. In that case, the node cannot remove the earlier internal text. Increase `max_tokens` if you see unfinished thinking/channel output.
|
||||
`LLM Session Chat` and `LLM Session Chat (Simple)` expose optional Autogrow
|
||||
media inputs for the current user turn. `media_0` is initially visible;
|
||||
connecting it reveals `media_1`, and the sequence can grow to nine connectors.
|
||||
Inputs are processed in numeric connector order. ComfyUI builds without the V3
|
||||
Autogrow API retain the legacy single `media` connector.
|
||||
|
||||
Supported inputs:
|
||||
|
||||
- `IMAGE`: sent as one `image_url` message part
|
||||
- `IMAGE` batch: sent as multiple `image_url` message parts
|
||||
- ComfyUI `AUDIO`: accepted only when the selected model is detected as
|
||||
Gemma 4; the node encodes the audio object as WAV and sends it as an
|
||||
`input_audio` message part
|
||||
|
||||
Multiple connectors may contain separate IMAGE batches or a mixture of IMAGE
|
||||
and AUDIO inputs. IMAGE batches are flattened into individual `image_url`
|
||||
parts. For turns containing AUDIO, the text part is placed before all media
|
||||
parts; the media parts otherwise preserve connector order.
|
||||
|
||||
### Referring To Connected Media In Prompts
|
||||
|
||||
Connector names such as `media_0` and `media_1` are UI/workflow identifiers;
|
||||
they are not inserted into the prompt or sent to the model as labels. Refer to
|
||||
media by its presented order and type instead:
|
||||
|
||||
- `Compare the first image with the second image.`
|
||||
- `Describe the image, then summarize the first audio clip.`
|
||||
- `1枚目の画像と2枚目の画像の違いを説明してください。`
|
||||
- `画像の内容を踏まえて、最初の音声を要約してください。`
|
||||
|
||||
Images are numbered after IMAGE batches are flattened. For example, if
|
||||
`media_0` contains a three-image batch and `media_1` contains one image, the
|
||||
image from `media_1` is the fourth image presented to the model. Audio clips
|
||||
follow the connector order among AUDIO inputs. Do not rely on writing
|
||||
`media_0` in the prompt unless the prompt itself separately defines what that
|
||||
name means; the model does not otherwise know the connector name.
|
||||
|
||||
For Gemma 4 media turns, the current JamePeng `Gemma4ChatHandler` still expects
|
||||
a valid `clip_model_path`, so configure a matching mmproj file even when testing
|
||||
Gemma 4 12B Unified AUDIO input.
|
||||
|
||||
Unsupported media inputs stop execution with an explicit error. AUDIO input for
|
||||
non-Gemma 4 models is also rejected explicitly.
|
||||
|
||||
Media input is turn-local. Raw media is not saved to session history. When media
|
||||
is present, KV state restore/save is disabled for that turn to avoid reusing
|
||||
state across different multimodal contexts.
|
||||
|
||||
When an older workflow is loaded on an Autogrow-capable ComfyUI build, linked
|
||||
`image` or `media` inputs are migrated to `media_0`. Save the workflow after
|
||||
loading to persist the updated connector name.
|
||||
|
||||
Compatibility depends on the selected `llama-cpp-python` backend and chat
|
||||
handler. See [COMPATIBILITY.md](COMPATIBILITY.md) before relying on AUDIO input.
|
||||
|
||||
---
|
||||
|
||||
@@ -179,6 +257,10 @@ Controls persistent disk cache.
|
||||
|
||||
`LlamaDiskCache` is scoped per `session_id`. Inside each session cache root, cache entries are further separated by model settings so incompatible configurations do not share the same disk cache directory.
|
||||
|
||||
When enabled, disk cache data is stored under `history_dir/cache/<session_id>/`.
|
||||
`reset_session` does not delete disk cache. To start with a different cache
|
||||
namespace, use a different `session_id`.
|
||||
|
||||
Availability depends on the `llama-cpp-python` build. If disk cache support is unstable in your environment, use `off`.
|
||||
|
||||
---
|
||||
@@ -191,6 +273,13 @@ Controls fast in-memory caches usage during conversation.
|
||||
- LlamaTrieCache (recommended)
|
||||
- off
|
||||
|
||||
`LLM Dialogue Cycle` keeps model managers loaded during and after execution
|
||||
when `runtime_cache` is `KV_cache` or `LlamaTrieCache`. For other runtime cache
|
||||
modes, it unloads managers between turns and at execution end.
|
||||
|
||||
To release VRAM explicitly after a keep-loaded run, execute `Unload LLM Model`
|
||||
manually.
|
||||
|
||||
---
|
||||
|
||||
## Logging Parameters
|
||||
@@ -202,28 +291,58 @@ Controls verbosity of internal logs.
|
||||
- timing (recommended)
|
||||
- debug
|
||||
|
||||
`timing` keeps normal runtime logs concise. `debug` adds detailed diagnostics
|
||||
for troubleshooting, including per-turn phase timings, generation start
|
||||
settings, and heartbeat messages while long generation calls are still running.
|
||||
|
||||
Generation attempt logs report `token_limit`, which is the maximum number of
|
||||
new tokens allowed for that attempt. When available, they also report
|
||||
`completion_tokens` and `tokens_per_second`. If the backend does not return an
|
||||
exact token count, the node may report estimated values as
|
||||
`completion_tokens_est` and `tokens_per_second_est`.
|
||||
|
||||
For `LLM Dialogue Cycle`, per-turn logs are labeled by role and cycle number,
|
||||
for example `[LLM Dialogue Cycle A/1]` and `[LLM Dialogue Cycle B/1]`.
|
||||
|
||||
---
|
||||
|
||||
### mmprojA / mmprojB (Dialogue Cycle)
|
||||
Selects the projector file for each dialogue role when preparing
|
||||
image-capable dialogue workflows.
|
||||
|
||||
Current Dialogue Cycle inputs are text-only. For ordinary text dialogue, choose
|
||||
`(Not required)` to avoid mmproj auto-detection.
|
||||
|
||||
---
|
||||
|
||||
### suppress_backend_logs
|
||||
Suppresses verbose llama.cpp backend logs.
|
||||
|
||||
To raise the native log level from Simple-node JSON config, see
|
||||
`advanced_backend_kwargs.verbosity` in
|
||||
[ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md).
|
||||
|
||||
---
|
||||
|
||||
## Simple Node Overrides
|
||||
|
||||
Simple-node default values are defined in `config/simple_defaults.json`.
|
||||
|
||||
To change Simple-node defaults, either edit `config/simple_defaults.json`
|
||||
directly or create another JSON file and point `config_path` to it.
|
||||
|
||||
### config_path
|
||||
Optional JSON file used to override internal defaults in Simple nodes.
|
||||
Optional JSON file used to override Simple-node defaults without directly
|
||||
editing `config/simple_defaults.json`.
|
||||
|
||||
If the config is missing or invalid, built-in safe defaults are used.
|
||||
|
||||
---
|
||||
|
||||
### force_text_only (Dialogue Cycle Simple)
|
||||
Forces pure text-only execution.
|
||||
Disables mmproj auto-detection for both dialogue roles.
|
||||
|
||||
- Disables vision pathways
|
||||
- Ignores mmproj auto-detection
|
||||
- Improves reproducibility across environments
|
||||
For current text-only dialogue, use `True`.
|
||||
|
||||
---
|
||||
|
||||
@@ -260,6 +379,11 @@ This clearly signals the start of the assistant response and reduces echo.
|
||||
|
||||
Streams the LLM output to the console in real time.
|
||||
|
||||
When streamed output does not end with a newline, the runtime adds one before
|
||||
printing follow-up status logs so generated text and diagnostics stay on
|
||||
separate console lines. This display separator does not change the returned
|
||||
assistant text or saved history.
|
||||
|
||||
### Additional Helpful Settings
|
||||
|
||||
- temperature = 0.7–0.9
|
||||
@@ -269,6 +393,17 @@ Streams the LLM output to the console in real time.
|
||||
|
||||
---
|
||||
|
||||
## Chat Template Compatibility
|
||||
|
||||
Some GGUF models do not support the `system` role.
|
||||
|
||||
When needed, the node automatically falls back by merging system messages into
|
||||
user messages.
|
||||
|
||||
This behavior is generic and not model-specific.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Not all parameters are required for every model
|
||||
|
||||
+56
-42
@@ -3,54 +3,66 @@
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.2.1
|
||||
**Version:** 1.6.0
|
||||
**License:** GPL-3.0
|
||||
|
||||
**ComfyUI 上だけで動作するローカル LLM 実行環境**です。
|
||||
**ComfyUI 上だけで GGUF モデルを llama.cpp 経由で実行するローカル LLM 実行環境**です。
|
||||
Ollama などの外部ランタイムを必要とせず、ComfyUI のノードとして完結します。
|
||||
|
||||
llama.cpp を通じて GGUF モデルをサポートしており、Qwen、Llama、Mistral NeMo、Gemma、Phi-3 Mini など、主要なオープン LLM を利用できます。
|
||||
テキストチャット、画像解析、対応 backend での Gemma 4 音声入力に対応します。
|
||||
Llama、Mistral、Qwen、DeepSeek、GLM、Gemma、Phi、LLaVA、gpt-oss など、
|
||||
主要なオープンモデル系を利用できます。
|
||||
|
||||
ユーザーとモデルの対話だけでなく、**モデル同士の対話**にも対応しており、
|
||||
**観察・実験・分析**用途を主眼に設計されています。
|
||||
異なるモデルや役割設定が互いの応答をもとに、内容を発展させたり、批評・修正したりする用途や、実際のユーザー会話に近い形でプロンプトやロール設定が意図どおりに働くかを試す用途に使えます。
|
||||
|
||||
---
|
||||
|
||||
## このプロジェクトでできること
|
||||
## 設計の考え方
|
||||
|
||||
- ローカル GGUF LLM 向けの **ファイルベース・セッション管理**
|
||||
- **複数ターン会話を保持**できる ComfyUI ノード群
|
||||
- モデルの挙動・収束・破綻を観察するための実験環境
|
||||
- サーバーやデーモンを必要としない、ComfyUI 内完結構成
|
||||
- **ComfyUI ネイティブなノード**:チャットやモデル同士の対話を、ComfyUI の workflow 内で実行します
|
||||
- **セッションファーストの永続化**:会話を永続的な session ID を中心に管理し、対話ログと状態を保存して実行をまたいで再開できるようにします
|
||||
- **標準版と Simple 版のノード**:標準ノードは主要な実行時設定を UI に出し、Simple ノードは UI を小さく保ちながら高度な調整を JSON 設定に分離します
|
||||
- **Chat と Dialogue の分離**:ユーザーとモデルの会話、モデル同士の対話を、それぞれ専用のノード種別として扱います
|
||||
|
||||
---
|
||||
|
||||
## 提供されるノード
|
||||
|
||||
### LLM Session Chat
|
||||
セッションを保持する標準的なチャットノードです。すべての主要パラメータを調整できます。
|
||||
セッションを保持する標準的なチャットノードです。
|
||||
|
||||
### LLM Session Chat (Simple)
|
||||
デモや初期検証向けの簡易版ノードです。
|
||||
LLM Session Chat からパラメータ設定の UI 項目を減らした簡易ノードです。
|
||||
UI は少なく保ちつつ、JSON 設定ファイルでは標準ノードにない advanced parameters も指定できます。
|
||||
|
||||
- セッション保持あり
|
||||
- 内部で安全なデフォルト値を固定
|
||||
- JSON 設定ファイルによる上書きが可能
|
||||
どちらの Session Chat ノードも、現在ターン用の Autogrow media input を
|
||||
受け取れます。最初は `media_0` が表示され、接続すると `media_1` が現れ、
|
||||
最大9個まで追加できます。各コネクタは IMAGE、IMAGE バッチ、ComfyUI の AUDIO を
|
||||
受け付けます。AUDIO は Gemma 4 モデルの場合だけ許可され、WAV として backend に
|
||||
渡されます。メディアはコネクタ番号順に処理され、session history には保存されません。
|
||||
V3 Autogrow に対応していない古い ComfyUI では、従来の単一 `media` コネクタへ
|
||||
フォールバックします。古い workflow の `image` または `media` input は、Autogrow
|
||||
対応環境で読み込むと UI 上で `media_0` へ移行されます。読み込み後に一度保存すると
|
||||
workflow JSON も正常化されます。
|
||||
`media_0` などのコネクタ名はモデルへ送られないため、プロンプトでは
|
||||
「1枚目の画像」「2番目の音声」のように順序と種類で参照してください。
|
||||
IMAGE バッチを含む数え方と例は
|
||||
[PARAMETERS.md](PARAMETERS.md#referring-to-connected-media-in-prompts)を参照してください。
|
||||
|
||||
### LLM Dialogue Cycle
|
||||
モデル同士を対話させるためのノードです(グラフの循環は不要)。
|
||||
モデル同士を対話させるためのノードです。
|
||||
通常のノード接続では循環グラフになりやすい処理を、1 つのノード内で実行できます。
|
||||
現在の Dialogue Cycle はテキスト入力のみです。`mmprojA` / `mmprojB` は
|
||||
画像対応 Dialogue ワークフロー向けに残してあり、通常のテキスト対話では
|
||||
`(Not required)` を選べます。
|
||||
|
||||
### LLM Dialogue Cycle (Simple)
|
||||
役割付き対話を観察することに特化した簡易版です。
|
||||
LLM Dialogue Cycle からパラメータ設定の UI 項目を減らした簡易ノードです。
|
||||
UI は少なく保ちつつ、JSON 設定ファイルでは標準ノードにない advanced parameters も指定できます。
|
||||
|
||||
---
|
||||
|
||||
## 設計の考え方
|
||||
|
||||
- **セッションファースト設計**:実行をまたいで会話が継続します
|
||||
- **ファイルベース永続化**:外部 DB や状態管理は不要
|
||||
- **観察重視**:対話ログ自体が主要な成果物です
|
||||
- **決定論的なターン実行**:分析・比較に向いています
|
||||
### Unload LLM Model
|
||||
読み込み済みの LLM を手動で VRAM から解放するための出力ノードです。
|
||||
|
||||
---
|
||||
|
||||
@@ -63,24 +75,28 @@ cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/kantan-kanto/ComfyUI-LLM-Session.git
|
||||
```
|
||||
|
||||
### 2. 依存関係のインストール
|
||||
### 2. LLM 以外の依存関係のインストール
|
||||
|
||||
```bash
|
||||
cd ComfyUI-LLM-Session
|
||||
pip install -r requirements.txt
|
||||
pip install pillow numpy
|
||||
```
|
||||
|
||||
### 3. llama-cpp-python について
|
||||
### 3. llama-cpp-python のインストール
|
||||
|
||||
llama-cpp-python のビルド内容によって、対応モデルや Vision 機能の挙動が変わります。
|
||||
|
||||
- 公式ビルドは多くの text-only 用途で利用できます。
|
||||
- 新しいマルチモーダルモデルでは、現在の backend build に必要な chat handler が含まれていない場合があります。
|
||||
- Vision 実行時に必要な multimodal chat handler がないというエラーが出る場合は、OS、Python バージョン、アクセラレーション backend に合う build を JamePeng llama-cpp-python で確認してください:
|
||||
- 新しい Vision / multimodal モデルを使う場合は、OS、Python バージョン、アクセラレーション backend に合う最新の JamePeng llama-cpp-python build を確認してください:
|
||||
https://github.com/JamePeng/llama-cpp-python
|
||||
- PyPI で配布されている release は多くの text-only 用途で利用できますが、新しい multimodal chat handler には対応していない場合があります。
|
||||
|
||||
詳細は **[COMPATIBILITY.md](COMPATIBILITY.md)** を参照してください。
|
||||
|
||||
**Text-only の簡易 fallback:**
|
||||
|
||||
```bash
|
||||
pip install llama-cpp-python
|
||||
```
|
||||
|
||||
### 4. モデル配置
|
||||
|
||||
GGUF モデルを以下に配置します。
|
||||
@@ -93,19 +109,17 @@ ComfyUI/models/LLM/
|
||||
|
||||
## Simple ノードの重要設定(要点)
|
||||
|
||||
Simple ノードのデフォルト値は `config/simple_defaults.json` で定義されています。
|
||||
変更したい場合は、このファイルを直接編集するか、別の JSON ファイルを用意して `config_path` で指定できます。
|
||||
|
||||
- **history_dir**:同じディレクトリを使う限り、会話が継続されます
|
||||
- **config_path**:Simple ノードの内部デフォルトを JSON で上書きできます
|
||||
- **force_text_only**(Dialogue Cycle Simple):Vision 経路を無効化し、再現性を高めます
|
||||
- **tensor_split**(`config/simple_defaults.json`):llama.cpp の multi-GPU 分割を指定できます
|
||||
- **enable_thinking**:対応モデル形式(Gemma 4 / Qwen3.5・Qwen3.6 互換経路)の thinking 出力制御に使います
|
||||
- **config_path**:`config/simple_defaults.json` を直接編集せずに Simple ノードのデフォルト値を上書きできます
|
||||
- **force_text_only**(Dialogue Cycle Simple):mmproj の自動検出を無効化します。
|
||||
現在のテキストのみの Dialogue では `True` を使います
|
||||
|
||||
## 生成上限
|
||||
|
||||
- Full UI ノードでは `max_tokens` を最大 `32768`、`n_ctx` を最大 `131072` まで設定できます。
|
||||
- デフォルト値は控えめに、`max_tokens=512`、`n_ctx=4096` のままです。
|
||||
- 長文生成では `max_tokens` と一緒に `n_ctx` も増やしてください。たとえば `max_tokens=8192` なら、短い入力でも `n_ctx=16384` 以上、履歴や長い本文を含める場合は `24576` または `32768` がより安全です。
|
||||
|
||||
詳細設定は **[PARAMETERS.md](PARAMETERS.md)** を参照してください。
|
||||
主要なパラメータの説明は **[PARAMETERS.md](PARAMETERS.md)** を参照してください。
|
||||
Simple ノードの advanced JSON 設定については
|
||||
**[ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md)** を参照してください。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,67 +1,91 @@
|
||||
# ComfyUI-LLM-Session
|
||||
[en | [ja](README.ja.md)]
|
||||
|
||||
**Version:** 1.2.2
|
||||
**Version:** 1.6.0
|
||||
**License:** GPL-3.0
|
||||
|
||||
A local LLM execution environment that runs entirely inside **ComfyUI**,
|
||||
without external runtimes such as Ollama.
|
||||
A local LLM execution environment that runs GGUF models via llama.cpp entirely inside **ComfyUI**, without external runtimes such as Ollama.
|
||||
|
||||
Supports GGUF models via llama.cpp, including many popular open-weight LLMs
|
||||
such as Llama, Mistral, Qwen, DeepSeek, GLM, Gemma, LLaVA and gpt-oss.
|
||||
Supports text chat, image analysis, and Gemma 4 audio input on compatible backends across many popular open-weight model families such as Llama, Mistral, Qwen, DeepSeek, GLM, Gemma, Phi, LLaVA, and gpt-oss.
|
||||
|
||||
In addition to user–model chat, it also supports model-to-model dialogue
|
||||
for **observation, experimentation, and analysis**.
|
||||
In addition to user–model chat, it also supports model-to-model dialogue where different models or roles can build on, critique, and revise each other's responses, or simulate conversations to test whether prompts and role settings behave as intended.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Upgrade Notes for Existing Users</strong></summary>
|
||||
|
||||
The following notes are intended for existing users upgrading to the `1.1.x` / `1.2.x` series.
|
||||
The following notes are intended for existing users upgrading to the `1.1.x` / `1.2.x` / `1.3.x` series.
|
||||
|
||||
- Cache-related setting names have changed. The previous `prompt_cache_mode` / `kv_state_mode` options have been reorganized into `persistent_cache` / `runtime_cache`.
|
||||
- The cache storage directory name has changed from `prompt_cache/` to `cache/`. Existing cache data is not migrated automatically.
|
||||
- `reset_session` now clears history and per-session KV state, while keeping the session's disk cache so the same session can restart efficiently.
|
||||
- Older history JSON files are normalized automatically, but the tracking model for summarized ranges has changed. Long-lived sessions may therefore behave somewhat differently from previous versions.
|
||||
- When using Vision models, both mmproj auto-detection and handler selection logic have changed. Even combinations that worked before may need to be rechecked depending on backend behavior and filename conventions.
|
||||
- On ComfyUI builds with V3 Autogrow support, the optional Session Chat media input starts at `media_0` and adds the next connector when one is linked. Older ComfyUI builds retain the single `media` connector.
|
||||
- Old workflows containing `image` or `media` inputs are migrated in the ComfyUI UI to `media_0` when Autogrow is available. Save the workflow once after loading to persist the normalized input in the workflow JSON.
|
||||
- IMAGE batches and multiple IMAGE connectors are sent as multiple image message parts.
|
||||
- ComfyUI `AUDIO` media is accepted only for Gemma 4 models. Unsupported media inputs, including AUDIO for other model families, now fail with explicit errors before model loading.
|
||||
- `LLM Dialogue Cycle` now keeps model managers loaded when `runtime_cache` is `KV_cache` or `LlamaTrieCache`.
|
||||
- Added `Unload LLM Model` output node for explicit manual VRAM release after keep-loaded runs.
|
||||
- History loading now restores from `*.bak` when the primary history JSON is invalid or missing.
|
||||
- Adaptive retry now recognizes additional context-overflow error wording (`context window ... exceed ...`).
|
||||
|
||||
For details, see the `1.1.x` and `1.2.x` sections in [CHANGELOG.md](CHANGELOG.md). For Vision / backend-specific differences, see [COMPATIBILITY.md](COMPATIBILITY.md).
|
||||
For details, see the `1.1.x`, `1.2.x`, and `1.3.x` sections in [CHANGELOG.md](CHANGELOG.md). For Vision / backend-specific differences, see [COMPATIBILITY.md](COMPATIBILITY.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## What This Project Is
|
||||
## Key Design Concepts
|
||||
|
||||
- A **file-based session system** for local GGUF LLMs
|
||||
- A set of ComfyUI nodes for **persistent multi-turn conversations**
|
||||
- A tool for **observing model behavior**, convergence, and failure modes
|
||||
- Fully self-contained inside ComfyUI (no server, no daemon)
|
||||
- **ComfyUI-native nodes**: chat and model-to-model dialogue run inside ComfyUI
|
||||
workflows
|
||||
- **Session-first persistence**: conversations are organized around persistent
|
||||
session IDs, with transcripts and state saved so sessions can resume across
|
||||
executions
|
||||
- **Standard and Simple node variants**: standard nodes expose the main runtime
|
||||
controls, while Simple nodes keep the UI small and move advanced tuning into
|
||||
JSON config
|
||||
- **Separate chat and dialogue workflows**: user-model chat and model-to-model
|
||||
dialogue are handled by dedicated node types
|
||||
|
||||
---
|
||||
|
||||
## Provided Nodes
|
||||
|
||||
### LLM Session Chat
|
||||
Standard session-based chat node with full parameter control.
|
||||
A standard chat node that keeps session history.
|
||||
|
||||
### LLM Session Chat (Simple)
|
||||
A minimal UI version with safe defaults, designed for quick testing and demos.
|
||||
A simplified node with fewer parameter controls in the UI than
|
||||
LLM Session Chat. The UI stays minimal, while the JSON config file can also set
|
||||
advanced parameters that are not available on the standard node.
|
||||
|
||||
- Session persistence included
|
||||
- Parameters fixed internally
|
||||
- Optional external config override (JSON)
|
||||
Both Session Chat nodes accept optional Autogrow media inputs for the current
|
||||
turn. The first connector is `media_0`; connecting it reveals `media_1`, up to
|
||||
nine connectors. Each connector accepts IMAGE tensors, IMAGE batches, and
|
||||
ComfyUI AUDIO objects. AUDIO input is only accepted for Gemma 4 models and is
|
||||
encoded as WAV before being passed to the backend. Media is processed in
|
||||
connector-number order and is not saved in the session history. On older
|
||||
ComfyUI builds without V3 Autogrow, the nodes fall back to one `media` connector.
|
||||
The connector names are not sent to the model, so prompts should refer to
|
||||
attachments by order and type, such as "the first image" or "the second audio
|
||||
clip." See [PARAMETERS.md](PARAMETERS.md#referring-to-connected-media-in-prompts)
|
||||
for IMAGE-batch numbering details and examples.
|
||||
|
||||
### LLM Dialogue Cycle
|
||||
Model-to-model dialogue execution without graph cycles.
|
||||
A node for running dialogue between models.
|
||||
It runs inside a single node so workflows do not need to rely on cyclic graph
|
||||
connections.
|
||||
Dialogue Cycle is currently text-only; `mmprojA` / `mmprojB` are kept for
|
||||
image-capable dialogue workflows and can be set to `(Not required)` for normal
|
||||
text dialogue.
|
||||
|
||||
### LLM Dialogue Cycle (Simple)
|
||||
Minimal version focused on **role-based dialogue observation**.
|
||||
A simplified node with fewer parameter controls in the UI than
|
||||
LLM Dialogue Cycle. The UI stays minimal, while the JSON config file can also
|
||||
set advanced parameters that are not available on the standard node.
|
||||
|
||||
### Unload LLM Model
|
||||
A utility output node that manually unloads the current LLM from VRAM.
|
||||
@@ -70,15 +94,6 @@ After running, set it back to false to avoid repeated unloads.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Concepts
|
||||
|
||||
- **Session-first design**: sessions live beyond a single execution
|
||||
- **File-based persistence**: no external state or database
|
||||
- **Observation-oriented**: transcripts are first-class outputs
|
||||
- **Deterministic turn execution**: suitable for analysis
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone Repository
|
||||
@@ -89,33 +104,32 @@ cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/kantan-kanto/ComfyUI-LLM-Session.git
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
### 2. Install Non-LLM Dependencies
|
||||
|
||||
```bash
|
||||
cd ComfyUI-LLM-Session
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Alternative manual installation:**
|
||||
```bash
|
||||
pip install pillow numpy
|
||||
```
|
||||
|
||||
### 3. Install llama-cpp-python (Notes)
|
||||
### 3. Install llama-cpp-python
|
||||
|
||||
Model compatibility depends on the llama-cpp-python build.
|
||||
Vision support varies significantly by backend and environment.
|
||||
|
||||
- Official releases work for many text-only workflows.
|
||||
- Newer multimodal model families can require chat handlers that are only
|
||||
available in recent backend builds.
|
||||
- If a Vision request fails because the required multimodal chat handler is
|
||||
unavailable, check the upstream JamePeng llama-cpp-python project and choose
|
||||
a build that matches your OS, Python version, and acceleration backend:
|
||||
- For newer Vision / multimodal models, use a recent JamePeng
|
||||
llama-cpp-python build that matches your OS, Python version, and
|
||||
acceleration backend:
|
||||
https://github.com/JamePeng/llama-cpp-python
|
||||
- PyPI releases work for many text-only workflows, but may not support
|
||||
newer multimodal chat handlers.
|
||||
|
||||
See [COMPATIBILITY.md](COMPATIBILITY.md) for detailed environment test results.
|
||||
|
||||
**Text-only quick start fallback:**
|
||||
|
||||
```bash
|
||||
pip install llama-cpp-python
|
||||
```
|
||||
|
||||
### 4. Place Models
|
||||
|
||||
Place your GGUF models in `ComfyUI/models/LLM/`:
|
||||
@@ -131,45 +145,37 @@ ComfyUI/models/LLM/
|
||||
When using Vision-capable models, please follow these rules:
|
||||
|
||||
- Place the **model** and **mmproj** GGUF files in the **same folder**.
|
||||
- The model filename must start with one of the following prefixes and end with `.gguf`:
|
||||
|
||||
`llava-1-5, llava15, llava-v1.5, llava-1-6, llava16, llava-v1.6, moondream2, nanollava, llama-3, llama3, minicpm-v-2.6, minicpm-v-2_6, minicpmv26, minicpm-v-4.0, minicpm-v-4_0, minicpmv40, minicpm-v-4.5, minicpm-v-4_5, minicpmv45, minicpm-v-4.6, minicpm-v-4_6, minicpmv46, gemma3, gemma-3, gemma_3, gemma4, gemma-4, gemma_4, glm4.1v, glm4_1v, glm41v, glm-4.1v, glm4.6v, glm4_6v, glm46v, glm-4.6v, granitedocling, granite-docling, lfm2-vl, lfm2vl, lfm2.5-vl, lfm2.5vl, lfm2_5-vl, lfm2_5vl, paddleocr, qwen2.5-vl, qwen2_5-vl, qwen25vl, qwen3-vl, qwen3vl, qwen3.5, qwen3_5, qwen35, qwen3.6, qwen3_6, qwen36, step3-vl, step3vl`
|
||||
|
||||
- The model filename must start with one of the supported model-family prefixes
|
||||
listed below and end with `.gguf`.
|
||||
- The mmproj filename must start with `mmproj-` and end with `.gguf`.
|
||||
- If exactly one file matching
|
||||
`mmproj-*[llava-1-5|llava15|llava-v1.5|llava-1-6|llava16|llava-v1.6|moondream2|nanollava|llama-3|llama3|minicpm-v-2.6|minicpm-v-2_6|minicpmv26|minicpm-v-4.0|minicpm-v-4_0|minicpmv40|minicpm-v-4.5|minicpm-v-4_5|minicpmv45|minicpm-v-4.6|minicpm-v-4_6|minicpmv46|gemma3|gemma-3|gemma_3|gemma4|gemma-4|gemma_4|glm4.1v|glm4_1v|glm41v|glm-4.1v|glm4.6v|glm4_6v|glm46v|glm-4.6v|granitedocling|granite-docling|lfm2-vl|lfm2vl|lfm2.5-vl|lfm2.5vl|lfm2_5-vl|lfm2_5vl|paddleocr|qwen2.5-vl|qwen2_5-vl|qwen25vl|qwen3-vl|qwen3vl|qwen3.5|qwen3_5|qwen35|qwen3.6|qwen3_6|qwen36|step3-vl|step3vl]*.gguf`
|
||||
exists in the folder, it can be selected automatically via Auto-detect.
|
||||
- Filename matching is **case-insensitive**.
|
||||
- Both GGUF extension matching (for example `.gguf`, `.GGUF`) and `mmproj` prefix matching (for example `mmproj-`, `MMPROJ-`) are case-insensitive.
|
||||
- If exactly one matching mmproj file in the folder contains one of the
|
||||
supported model-family aliases listed below in its filename, it can be
|
||||
selected automatically via Auto-detect.
|
||||
- Filename matching is **case-insensitive**, including `.gguf` / `.GGUF` and
|
||||
`mmproj-` / `MMPROJ-`.
|
||||
|
||||
Supported model-family prefixes / aliases:
|
||||
|
||||
`llava-1-5, llava15, llava-v1.5, llava-1-6, llava16, llava-v1.6, moondream2, nanollava, llama-3, llama3, minicpm-v-2.6, minicpm-v-2_6, minicpmv26, minicpm-v-4.0, minicpm-v-4_0, minicpmv40, minicpm-v-4.5, minicpm-v-4_5, minicpmv45, minicpm-v-4.6, minicpm-v-4_6, minicpmv46, gemma3, gemma-3, gemma_3, gemma4, gemma-4, gemma_4, glm4.1v, glm4_1v, glm41v, glm-4.1v, glm4.6v, glm4_6v, glm46v, glm-4.6v, granitedocling, granite-docling, lfm2-vl, lfm2vl, lfm2.5-vl, lfm2.5vl, lfm2_5-vl, lfm2_5vl, paddleocr, qwen2.5-vl, qwen2_5-vl, qwen25vl, qwen3-vl, qwen3vl, qwen3.5, qwen3_5, qwen35, qwen-3.5, qwen-3_5, qwen3.6, qwen3_6, qwen36, qwen-3.6, qwen-3_6, qwen3.8, qwen3_8, qwen38, qwen-3.8, qwen-3_8, step3-vl, step3vl`
|
||||
|
||||
---
|
||||
|
||||
### Simple Node Settings (Quick Notes)
|
||||
## Simple Node Settings (Quick Notes)
|
||||
|
||||
Simple-node defaults are defined in `config/simple_defaults.json`.
|
||||
To change them, you can either edit that file directly or create another JSON
|
||||
file elsewhere and select it with `config_path`.
|
||||
|
||||
- **history_dir**: Conversations persist as long as the same directory is used.
|
||||
- **config_path**: Optional JSON file to override internal defaults in Simple nodes.
|
||||
For fixed seed generation and other advanced Simple-node JSON settings, see
|
||||
[ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md).
|
||||
- **force_text_only** (Dialogue Cycle Simple): Forces pure text mode to avoid mmproj / vision handler differences and improve reproducibility.
|
||||
- **config_path**: Optional JSON file used to override Simple-node defaults
|
||||
without directly editing `config/simple_defaults.json`.
|
||||
- **force_text_only** (Dialogue Cycle Simple): Disables mmproj auto-detection.
|
||||
For current text-only dialogue, use `True`.
|
||||
- **reset_session** (Dialogue Cycle Simple): Overwrites the history and summary files associated with the session name, and resets per-session KV state. The session's disk cache is kept.
|
||||
- **tensor_split** (`config/simple_defaults.json`): Optional llama.cpp multi-GPU split. For example, `"tensor_split": [1.0, 0.0]` keeps llama.cpp model offload on visible GPU 0 in a 2-GPU setup. Leave it `null` or omit it for default behavior.
|
||||
|
||||
### Generation Limits
|
||||
|
||||
- Full UI nodes allow `max_tokens` up to `32768` and `n_ctx` up to `131072`.
|
||||
- Defaults remain conservative: `max_tokens=512` and `n_ctx=4096`.
|
||||
- For long-form generation, raise `n_ctx` together with `max_tokens`; for example, `max_tokens=8192` usually needs at least `n_ctx=16384`, and `24576` or `32768` is safer with history or long source text.
|
||||
|
||||
### Cache Scope Notes
|
||||
|
||||
- `persistent_cache = LlamaDiskCache` stores disk cache data under `history_dir/cache/<session_id>/`.
|
||||
- Disk cache is isolated per session id, then split by model settings inside that session cache root.
|
||||
- `reset_session` does not delete disk cache. To start with a different cache namespace, use a different `session_id`.
|
||||
- `LLM Dialogue Cycle` keeps model managers loaded during and after execution when `runtime_cache` is `KV_cache` or `LlamaTrieCache`.
|
||||
- For other runtime cache modes, `LLM Dialogue Cycle` unloads managers between turns and at execution end.
|
||||
- To release VRAM explicitly after a keep-loaded run, execute `Unload LLM Model` manually.
|
||||
|
||||
See [PARAMETERS.md](PARAMETERS.md) for the full list of settings and advanced usage.
|
||||
For the main parameter reference, see [PARAMETERS.md](PARAMETERS.md).
|
||||
For advanced Simple-node JSON settings, see
|
||||
[ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -232,19 +238,23 @@ Demonstrates model-to-model dialogue with separate roles and a full transcript o
|
||||
## Model Compatibility (Tested)
|
||||
|
||||
The following GGUF instruction models have been tested.
|
||||
For detailed environment results and backend-specific notes, see
|
||||
[COMPATIBILITY.md](COMPATIBILITY.md). Some newer multimodal model families
|
||||
depend on recent [JamePeng/llama-cpp-python](https://github.com/JamePeng/llama-cpp-python)
|
||||
builds for the required chat handlers.
|
||||
|
||||
### Text Chat (Confirmed)
|
||||
|
||||
- DeepSeek
|
||||
- Gemma 2 Instruct (2B / 9B)
|
||||
- Gemma 3 Instruct (4B / 12B)
|
||||
- Gemma 4 (E2B / E4B /31B / 26B-A4B)*
|
||||
- Gemma 4 (E2B / E4B / 12B / 26B-A4B / 31B)
|
||||
- GLM-4.6V Flash*
|
||||
- gpt-oss
|
||||
- Llama 3.1 Instruct (8B / 70B)
|
||||
- LLaVA
|
||||
- MiniCPM-V 2.6
|
||||
- MiniCPM-V 4.6
|
||||
- MiniCPM-V 4.6*
|
||||
- Mistral NeMo 12B Instruct
|
||||
- Nemotron-Nano*
|
||||
- Phi-3 Mini Instruct
|
||||
@@ -255,38 +265,38 @@ The following GGUF instruction models have been tested.
|
||||
- Qwen3-VL (4B / 8B)
|
||||
- Qwen3.5 (9B / 27B / 35B-A3B)*
|
||||
- Qwen3.6 (27B / 35B-A3B)*
|
||||
- Qwen3.8 (27B)*
|
||||
|
||||
**Note:** Entries marked with `*` either do not work on official llama-cpp-python 0.3.16 or have not been tested on it.
|
||||
**Note:** Entries marked with `*` either do not work on PyPI `llama-cpp-python` or have not been tested on it.
|
||||
|
||||
Qwen3.8 is detected as its own model family while reusing the compatible
|
||||
`Qwen35ChatHandler`. Vision recognition has been manually confirmed. Simple
|
||||
nodes also support Qwen3.8 `reasoning_effort` through JSON configuration.
|
||||
|
||||
### MoE Models
|
||||
|
||||
- MoE models can work depending on backend support
|
||||
- Qwen3-30B-A3B, Qwen3.5/3.6-35B-A3B confirmed working
|
||||
- Qwen3-30B-A3B, Qwen3.5/3.6-35B-A3B, and Gemma4-26B-A4B confirmed working
|
||||
- Mixtral GGUF may fail to load depending on llama.cpp / llama-cpp-python build
|
||||
|
||||
### Vision Models
|
||||
|
||||
- Vision support depends on model + mmproj + backend
|
||||
- IMAGE batch input is sent as multiple image message parts
|
||||
- Gemma 4 AUDIO input depends on backend support for `input_audio` message parts
|
||||
- Some vision-capable models may ignore images without errors
|
||||
- Text-only operation is always supported
|
||||
|
||||
---
|
||||
|
||||
## Chat Template Compatibility
|
||||
|
||||
Some GGUF models do not support the `system` role.
|
||||
|
||||
This project automatically falls back by merging system messages into user messages when needed.
|
||||
|
||||
This behavior is generic and not model-specific.
|
||||
|
||||
---
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Performance strongly depends on model size and quantization
|
||||
- Large models may be extremely slow on CPU
|
||||
- Long sessions benefit from summarization and history management
|
||||
- For informal local image-input speed results, see
|
||||
[BENCHMARKS.md](BENCHMARKS.md). These results are hardware-dependent and
|
||||
intended only as rough guidance.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,10 +349,8 @@ Areas needing help:
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for detailed version history.
|
||||
|
||||
### Current Version: 1.2.2
|
||||
### Current Version: 1.6.0
|
||||
|
||||
- Improved Vision model diagnostics when a required multimodal chat handler is unavailable.
|
||||
- Added MiniCPM-V-4.6 aliases, chat-handler mapping, and Text-only prompt support.
|
||||
- Added advanced JSON-based parameter settings for Simple-node config files.
|
||||
- Added [ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md) for advanced JSON settings and current limitations.
|
||||
- Kept the default `llama-cpp-python` dependency conservative while documenting backend compatibility notes for newer Vision models.
|
||||
- Added Simple-node JSON config `advanced_generation_kwargs.image_max_pixels` to control how much IMAGE detail reaches Vision models; `1048576` is recommended for Gemma 4 and Qwen3.x.
|
||||
- Gemma 4 Vision now uses a fixed 512-token per-image limit, so it uses finer detail when `image_max_pixels` is raised without risking a llama.cpp abort.
|
||||
- See [ADVANCED_PARAMETERS.md](ADVANCED_PARAMETERS.md) for per-model token and pixel guidance.
|
||||
|
||||
+4
-2
@@ -6,7 +6,7 @@
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
|
||||
__version__ = "1.2.2"
|
||||
__version__ = "1.6.0"
|
||||
|
||||
try:
|
||||
from .llm_session_nodes import (
|
||||
@@ -30,4 +30,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS.update(sNODE_CLASS_MAPPINGS)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(sNODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "__version__"]
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY", "__version__"]
|
||||
|
||||
@@ -26,12 +26,14 @@
|
||||
"stream_to_console": true,
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": null,
|
||||
"top_k": null,
|
||||
"min_p": null,
|
||||
"top_k": 40,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
"typical_p": null,
|
||||
"mirostat_mode": null,
|
||||
"mirostat_tau": null,
|
||||
"mirostat_eta": null
|
||||
"mirostat_eta": null,
|
||||
"image_max_pixels": 1048576
|
||||
},
|
||||
"advanced_summary_generation_kwargs": {
|
||||
"seed": null,
|
||||
@@ -42,18 +44,27 @@
|
||||
"typical_p": null
|
||||
},
|
||||
"advanced_backend_kwargs": {
|
||||
"n_batch": 2048,
|
||||
"n_ubatch": 512,
|
||||
"verbosity": 1,
|
||||
"logits_all": null,
|
||||
"ctx_checkpoints": null,
|
||||
"checkpoint_on_device": null,
|
||||
"verbosity": null,
|
||||
"log_filters": null
|
||||
},
|
||||
"qwen3.5": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"qwen3.8": {
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "medium",
|
||||
"official_sampling_override": false
|
||||
},
|
||||
"minicpm-v-4.6": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"gemma4": {
|
||||
"enable_thinking": false
|
||||
"enable_thinking": false,
|
||||
"official_sampling_override": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,16 @@
|
||||
"qwen3.5": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"qwen3.8": {
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "medium",
|
||||
"official_sampling_override": false
|
||||
},
|
||||
"minicpm-v-4.6": {
|
||||
"enable_thinking": false
|
||||
},
|
||||
"gemma4": {
|
||||
"enable_thinking": false
|
||||
"enable_thinking": false,
|
||||
"official_sampling_override": false
|
||||
}
|
||||
}
|
||||
|
||||
+224
-50
@@ -4,15 +4,27 @@ from __future__ import annotations
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
|
||||
|
||||
try:
|
||||
from .logging_utils import get_module_logger, LOG_LEVEL_MINIMAL
|
||||
from .turn_types import GenerationRunResult
|
||||
except Exception:
|
||||
from core.logging_utils import get_module_logger, LOG_LEVEL_MINIMAL
|
||||
from core.turn_types import GenerationRunResult
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
_OPTIONAL_ADVANCED_GENERATION_KWARGS = ("top_k", "min_p", "present_penalty")
|
||||
_module_logger = get_module_logger("GenerationRunner")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GenerationOnceResult:
|
||||
assistant_text: str
|
||||
completion_tokens: Optional[int] = None
|
||||
completion_tokens_estimated: bool = False
|
||||
|
||||
|
||||
class _AbortWatch:
|
||||
@@ -69,6 +81,42 @@ def _false_interrupt_error(_err: Exception) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _HeartbeatWatch:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
heartbeat_logger: Optional[Callable[[float, int], None]],
|
||||
attempt_no: int,
|
||||
interval: float,
|
||||
) -> None:
|
||||
self._heartbeat_logger = heartbeat_logger
|
||||
self._attempt_no = int(attempt_no)
|
||||
self._interval = max(0.01, float(interval))
|
||||
self._started_at = time.perf_counter()
|
||||
self._stop_event = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def __enter__(self) -> "_HeartbeatWatch":
|
||||
if self._heartbeat_logger is None:
|
||||
return self
|
||||
self._thread = threading.Thread(target=self._watch, name="llm-session-heartbeat", daemon=True)
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self._stop_event.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def _watch(self) -> None:
|
||||
while not self._stop_event.wait(self._interval):
|
||||
try:
|
||||
assert self._heartbeat_logger is not None
|
||||
self._heartbeat_logger(time.perf_counter() - self._started_at, self._attempt_no)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _finish_reason(resp: Any) -> str:
|
||||
if not isinstance(resp, dict):
|
||||
return ""
|
||||
@@ -93,6 +141,44 @@ def _raise_if_abort_detected(
|
||||
raise RuntimeError("Generation aborted")
|
||||
|
||||
|
||||
def _usage_completion_tokens(resp: Any) -> Optional[int]:
|
||||
if not isinstance(resp, dict):
|
||||
return None
|
||||
usage = resp.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
return None
|
||||
value = usage.get("completion_tokens")
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return max(0, int(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_completion_tokens(llm: Any, text: str) -> Optional[int]:
|
||||
if not text:
|
||||
return 0
|
||||
tokenize = getattr(llm, "tokenize", None)
|
||||
if not callable(tokenize):
|
||||
return None
|
||||
data = text.encode("utf-8")
|
||||
try:
|
||||
return len(tokenize(data, add_bos=False))
|
||||
except TypeError:
|
||||
try:
|
||||
return len(tokenize(data))
|
||||
except TypeError:
|
||||
try:
|
||||
return len(tokenize(text))
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def run_with_typeerror_fallback(
|
||||
*,
|
||||
execute_with_kwargs: Callable[[Dict[str, Any]], T],
|
||||
@@ -100,17 +186,37 @@ def run_with_typeerror_fallback(
|
||||
retry_kwargs_with_repeat_last_n_fallback: Callable[[Dict[str, Any], int], Dict[str, Any]],
|
||||
repeat_last_n: int,
|
||||
) -> T:
|
||||
"""Retry execution with fallback kwargs on TypeError (max 3 attempts total)."""
|
||||
"""Retry known compatibility failures without swallowing unrelated TypeErrors."""
|
||||
active_kwargs = dict(completion_kwargs)
|
||||
try:
|
||||
return execute_with_kwargs(active_kwargs)
|
||||
except TypeError:
|
||||
active_kwargs = retry_kwargs_with_repeat_last_n_fallback(active_kwargs, repeat_last_n)
|
||||
legacy_fallbacks = 0
|
||||
while True:
|
||||
try:
|
||||
return execute_with_kwargs(active_kwargs)
|
||||
except TypeError:
|
||||
except TypeError as error:
|
||||
message = str(error).lower()
|
||||
unsupported_key = next(
|
||||
(
|
||||
key
|
||||
for key in _OPTIONAL_ADVANCED_GENERATION_KWARGS
|
||||
if key in active_kwargs
|
||||
and key in message
|
||||
and "unexpected keyword" in message
|
||||
),
|
||||
None,
|
||||
)
|
||||
if unsupported_key is not None:
|
||||
active_kwargs.pop(unsupported_key, None)
|
||||
_module_logger(
|
||||
"Warning: The installed llama-cpp-python backend rejected "
|
||||
f"advanced generation kwarg '{unsupported_key}'; retrying without it.",
|
||||
LOG_LEVEL_MINIMAL,
|
||||
)
|
||||
continue
|
||||
|
||||
if legacy_fallbacks >= 2:
|
||||
raise
|
||||
active_kwargs = retry_kwargs_with_repeat_last_n_fallback(active_kwargs, repeat_last_n)
|
||||
return execute_with_kwargs(active_kwargs)
|
||||
legacy_fallbacks += 1
|
||||
|
||||
|
||||
def _run_generation_once(
|
||||
@@ -126,9 +232,12 @@ def _run_generation_once(
|
||||
extract_stream_content: Callable[[Any], str],
|
||||
retry_kwargs_with_repeat_last_n_fallback: Callable[[Dict[str, Any], int], Dict[str, Any]],
|
||||
repeat_last_n: int,
|
||||
attempt_no: int,
|
||||
heartbeat_logger: Optional[Callable[[float, int], None]] = None,
|
||||
heartbeat_interval: float = 30.0,
|
||||
processing_interrupted: Callable[[], bool] = _noop_processing_interrupted,
|
||||
throw_if_processing_interrupted: Callable[[], None] = _noop_throw_if_processing_interrupted,
|
||||
) -> str:
|
||||
) -> _GenerationOnceResult:
|
||||
def _stream_execute(active_kwargs: Dict[str, Any]):
|
||||
if text_chat_request is not None:
|
||||
return llm.create_completion(
|
||||
@@ -149,51 +258,86 @@ def _run_generation_once(
|
||||
return create_chat_completion_robust(llm, messages, **active_kwargs)
|
||||
|
||||
with suppress_backend_logs_ctx:
|
||||
if stream_to_console:
|
||||
pieces: List[str] = []
|
||||
out = sys.__stdout__
|
||||
with _HeartbeatWatch(
|
||||
heartbeat_logger=heartbeat_logger,
|
||||
attempt_no=int(attempt_no),
|
||||
interval=float(heartbeat_interval),
|
||||
):
|
||||
if stream_to_console:
|
||||
pieces: List[str] = []
|
||||
out = sys.__stdout__
|
||||
last_token = ""
|
||||
wrote_token = False
|
||||
with _AbortWatch(llm=llm, processing_interrupted=processing_interrupted) as abort_watch:
|
||||
stream_iter = run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_stream_execute,
|
||||
completion_kwargs=completion_kwargs,
|
||||
retry_kwargs_with_repeat_last_n_fallback=retry_kwargs_with_repeat_last_n_fallback,
|
||||
repeat_last_n=int(repeat_last_n),
|
||||
)
|
||||
|
||||
for chunk in stream_iter:
|
||||
throw_if_processing_interrupted()
|
||||
_raise_if_abort_detected(
|
||||
abort_requested=abort_watch.abort_requested,
|
||||
resp={"choices": [chunk.get("choices", [{}])[0]]} if isinstance(chunk, dict) else chunk,
|
||||
throw_if_processing_interrupted=throw_if_processing_interrupted,
|
||||
)
|
||||
token = extract_stream_content(chunk)
|
||||
if not token:
|
||||
continue
|
||||
pieces.append(token)
|
||||
try:
|
||||
out.write(token)
|
||||
out.flush()
|
||||
last_token = token
|
||||
wrote_token = True
|
||||
except Exception:
|
||||
pass
|
||||
if wrote_token and not last_token.endswith(("\n", "\r")):
|
||||
try:
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
except Exception:
|
||||
pass
|
||||
assistant_text = "".join(pieces)
|
||||
completion_tokens = _estimate_completion_tokens(llm, assistant_text)
|
||||
return _GenerationOnceResult(
|
||||
assistant_text=assistant_text,
|
||||
completion_tokens=completion_tokens,
|
||||
completion_tokens_estimated=(completion_tokens is not None),
|
||||
)
|
||||
|
||||
with _AbortWatch(llm=llm, processing_interrupted=processing_interrupted) as abort_watch:
|
||||
stream_iter = run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_stream_execute,
|
||||
resp = run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_non_stream_execute,
|
||||
completion_kwargs=completion_kwargs,
|
||||
retry_kwargs_with_repeat_last_n_fallback=retry_kwargs_with_repeat_last_n_fallback,
|
||||
repeat_last_n=int(repeat_last_n),
|
||||
)
|
||||
_raise_if_abort_detected(
|
||||
abort_requested=abort_watch.abort_requested,
|
||||
resp=resp,
|
||||
throw_if_processing_interrupted=throw_if_processing_interrupted,
|
||||
)
|
||||
|
||||
for chunk in stream_iter:
|
||||
throw_if_processing_interrupted()
|
||||
_raise_if_abort_detected(
|
||||
abort_requested=abort_watch.abort_requested,
|
||||
resp={"choices": [chunk.get("choices", [{}])[0]]} if isinstance(chunk, dict) else chunk,
|
||||
throw_if_processing_interrupted=throw_if_processing_interrupted,
|
||||
)
|
||||
token = extract_stream_content(chunk)
|
||||
if not token:
|
||||
continue
|
||||
pieces.append(token)
|
||||
try:
|
||||
out.write(token)
|
||||
out.flush()
|
||||
except Exception:
|
||||
pass
|
||||
return "".join(pieces)
|
||||
|
||||
with _AbortWatch(llm=llm, processing_interrupted=processing_interrupted) as abort_watch:
|
||||
resp = run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_non_stream_execute,
|
||||
completion_kwargs=completion_kwargs,
|
||||
retry_kwargs_with_repeat_last_n_fallback=retry_kwargs_with_repeat_last_n_fallback,
|
||||
repeat_last_n=int(repeat_last_n),
|
||||
if text_chat_request is not None:
|
||||
assistant_text = resp["choices"][0]["text"] or ""
|
||||
else:
|
||||
assistant_text = resp["choices"][0]["message"]["content"] or ""
|
||||
completion_tokens = _usage_completion_tokens(resp)
|
||||
if completion_tokens is not None:
|
||||
return _GenerationOnceResult(
|
||||
assistant_text=assistant_text,
|
||||
completion_tokens=completion_tokens,
|
||||
completion_tokens_estimated=False,
|
||||
)
|
||||
completion_tokens = _estimate_completion_tokens(llm, assistant_text)
|
||||
return _GenerationOnceResult(
|
||||
assistant_text=assistant_text,
|
||||
completion_tokens=completion_tokens,
|
||||
completion_tokens_estimated=(completion_tokens is not None),
|
||||
)
|
||||
_raise_if_abort_detected(
|
||||
abort_requested=abort_watch.abort_requested,
|
||||
resp=resp,
|
||||
throw_if_processing_interrupted=throw_if_processing_interrupted,
|
||||
)
|
||||
|
||||
if text_chat_request is not None:
|
||||
return resp["choices"][0]["text"] or ""
|
||||
return resp["choices"][0]["message"]["content"] or ""
|
||||
|
||||
|
||||
def run_generation_with_adaptive_retry(
|
||||
@@ -217,7 +361,7 @@ def run_generation_with_adaptive_retry(
|
||||
on_state_cache_mismatch: Callable[[Exception], None],
|
||||
on_compact_summary: Callable[[], None],
|
||||
rebuild_messages_for_turns_limit: Callable[[Optional[int]], Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]],
|
||||
attempt_logger: Optional[Callable[[bool, int, float, int, Optional[int], Optional[Exception]], None]],
|
||||
attempt_logger: Optional[Callable[..., None]],
|
||||
debug_traceback: bool,
|
||||
traceback_print_exc: Callable[[], None],
|
||||
suppress_backend_logs_ctx_factory: Callable[[], Any],
|
||||
@@ -229,6 +373,8 @@ def run_generation_with_adaptive_retry(
|
||||
processing_interrupted: Callable[[], bool] = _noop_processing_interrupted,
|
||||
throw_if_processing_interrupted: Callable[[], None] = _noop_throw_if_processing_interrupted,
|
||||
is_interrupt_error: Callable[[Exception], bool] = _false_interrupt_error,
|
||||
heartbeat_logger: Optional[Callable[[float, int], None]] = None,
|
||||
heartbeat_interval: float = 30.0,
|
||||
) -> GenerationRunResult:
|
||||
assistant_text = ""
|
||||
gen_tokens = int(max_tokens)
|
||||
@@ -256,7 +402,7 @@ def run_generation_with_adaptive_retry(
|
||||
if isinstance(advanced_generation_kwargs, dict):
|
||||
completion_kwargs.update(advanced_generation_kwargs)
|
||||
|
||||
assistant_text = _run_generation_once(
|
||||
once_result = _run_generation_once(
|
||||
llm=llm,
|
||||
messages=messages,
|
||||
text_chat_request=text_chat_request,
|
||||
@@ -268,12 +414,25 @@ def run_generation_with_adaptive_retry(
|
||||
extract_stream_content=extract_stream_content,
|
||||
retry_kwargs_with_repeat_last_n_fallback=retry_kwargs_with_repeat_last_n_fallback,
|
||||
repeat_last_n=int(repeat_last_n),
|
||||
attempt_no=int(attempt_no),
|
||||
heartbeat_logger=heartbeat_logger,
|
||||
heartbeat_interval=float(heartbeat_interval),
|
||||
processing_interrupted=processing_interrupted,
|
||||
throw_if_processing_interrupted=throw_if_processing_interrupted,
|
||||
)
|
||||
assistant_text = once_result.assistant_text
|
||||
|
||||
if attempt_logger is not None:
|
||||
attempt_logger(True, attempt_no, time.perf_counter() - t_start, int(gen_tokens), turns_limit, None)
|
||||
attempt_logger(
|
||||
True,
|
||||
attempt_no,
|
||||
time.perf_counter() - t_start,
|
||||
int(gen_tokens),
|
||||
turns_limit,
|
||||
None,
|
||||
once_result.completion_tokens,
|
||||
once_result.completion_tokens_estimated,
|
||||
)
|
||||
|
||||
return GenerationRunResult(
|
||||
assistant_text=assistant_text,
|
||||
@@ -282,13 +441,24 @@ def run_generation_with_adaptive_retry(
|
||||
last_err=last_err,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
completion_tokens=once_result.completion_tokens,
|
||||
completion_tokens_estimated=once_result.completion_tokens_estimated,
|
||||
)
|
||||
except Exception as err:
|
||||
if is_interrupt_error(err):
|
||||
raise
|
||||
last_err = err
|
||||
if attempt_logger is not None:
|
||||
attempt_logger(False, attempt_no, time.perf_counter() - t_start, int(gen_tokens), turns_limit, err)
|
||||
attempt_logger(
|
||||
False,
|
||||
attempt_no,
|
||||
time.perf_counter() - t_start,
|
||||
int(gen_tokens),
|
||||
turns_limit,
|
||||
err,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
if debug_traceback:
|
||||
traceback_print_exc()
|
||||
|
||||
@@ -318,6 +488,8 @@ def run_generation_with_adaptive_retry(
|
||||
last_err=last_err,
|
||||
succeeded=False,
|
||||
non_ctx_error=True,
|
||||
completion_tokens=None,
|
||||
completion_tokens_estimated=False,
|
||||
)
|
||||
|
||||
return GenerationRunResult(
|
||||
@@ -327,4 +499,6 @@ def run_generation_with_adaptive_retry(
|
||||
last_err=last_err,
|
||||
succeeded=bool(assistant_text),
|
||||
non_ctx_error=False,
|
||||
completion_tokens=None,
|
||||
completion_tokens_estimated=False,
|
||||
)
|
||||
|
||||
@@ -20,3 +20,5 @@ class GenerationRunResult:
|
||||
last_err: Optional[Exception]
|
||||
succeeded: bool
|
||||
non_ctx_error: bool = False
|
||||
completion_tokens: Optional[int] = None
|
||||
completion_tokens_estimated: bool = False
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# Acceptable Silent Errors
|
||||
|
||||
- Status: Historical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Clarifying historical status, fixing stale cross-references, or adding supersession notes.
|
||||
|
||||
Historical note: This document is the silent-error allowlist from the 2026-03-22
|
||||
audit. Line-number references below refer to the code layout at the time of that
|
||||
audit and may not match current source files.
|
||||
|
||||
**Date**: 2026-03-22
|
||||
**Purpose**: Document `except Exception: pass` patterns that are intentionally kept silent
|
||||
|
||||
This document lists error handling locations where silent failure is acceptable and intentional. These patterns should NOT be modified to add logging unless there is a specific bug report or user impact.
|
||||
This document lists error handling locations where silent failure was considered
|
||||
acceptable and intentional during the 2026-03-22 audit.
|
||||
|
||||
---
|
||||
|
||||
## Criteria for Acceptable Silent Errors
|
||||
|
||||
An error handler can be kept silent when ALL of the following conditions are met:
|
||||
|
||||
1. **Fallback behavior exists**: The code has a safe default or fallback
|
||||
2. **No data loss risk**: Failure does not cause data corruption or loss
|
||||
3. **No functional impact**: Core functionality continues to work
|
||||
4. **Logging would add noise**: The error is expected in normal operation
|
||||
5. **Debug information available**: If needed, the issue can be diagnosed through other means
|
||||
For the current policy on when a silent handler is acceptable, see
|
||||
[`logging-guidelines.md`](logging-guidelines.md#when-silence-is-acceptable).
|
||||
|
||||
---
|
||||
|
||||
@@ -83,25 +83,7 @@ An error handler can be kept silent when ALL of the following conditions are met
|
||||
| Debug Information Retrieval | 8 | `llm_session_nodes.py` |
|
||||
| **Total** | **16** | 2 files |
|
||||
|
||||
---
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
These silent error handlers should be reviewed if:
|
||||
|
||||
1. **User reports issues** that may be related to these code paths
|
||||
2. **Debugging becomes difficult** due to lack of visibility
|
||||
3. **The fallback behavior changes** (e.g., defaults are removed)
|
||||
4. **The error becomes more frequent** due to external changes
|
||||
|
||||
When reviewing, consider:
|
||||
- Can we add conditional logging (only in debug mode)?
|
||||
- Can we add metrics/telemetry without affecting performance?
|
||||
- Is the fallback still appropriate?
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [`error-handling-audit.md`](error-handling-audit.md) - Full audit of all `except Exception:` occurrences
|
||||
- [`logging-guidelines.md`](logging-guidelines.md) - Logging utility usage guidelines
|
||||
- [`logging-guidelines.md`](logging-guidelines.md#when-silence-is-acceptable) - Current silent-handler policy and review triggers
|
||||
|
||||
@@ -1,43 +1,121 @@
|
||||
# Advanced Simple Config Notes
|
||||
# Advanced Simple Config Implementation Rules
|
||||
|
||||
This note records an advanced JSON configuration sketch for the Simple nodes.
|
||||
Only the seed read path has been implemented so far; the remaining options are
|
||||
kept as design notes until they have a concrete validation path.
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-10-08
|
||||
- Update when: Advanced Simple config support expands, validation rules change, or new advanced parameter categories are added.
|
||||
|
||||
## Branch
|
||||
This document defines maintainer-facing implementation rules and review points
|
||||
for adding advanced parameters to Simple-node JSON config.
|
||||
|
||||
Suggested branch name:
|
||||
`ADVANCED_PARAMETERS.md` is the user-facing reference for advanced parameters
|
||||
that are actually supported. This document is for implementation decisions,
|
||||
validation rules, compatibility cautions, and future expansion criteria.
|
||||
|
||||
```text
|
||||
draft/simple-advanced-config
|
||||
```
|
||||
|
||||
## Background
|
||||
## Purpose
|
||||
|
||||
The Simple nodes read a JSON config file through `config_path`. This makes them
|
||||
a natural place to expose advanced options without adding more controls to the
|
||||
ComfyUI node UI.
|
||||
|
||||
The idea was to add an example config such as:
|
||||
Advanced Simple config should remain intentionally narrow. Each new parameter
|
||||
must have a concrete use case, a clear runtime destination, validation rules,
|
||||
tests, and user-facing documentation after it becomes supported.
|
||||
|
||||
```text
|
||||
config/simple_advanced.example.json
|
||||
```
|
||||
## Current Supported Surface
|
||||
|
||||
This file would include the normal `simple_defaults.json` settings plus optional
|
||||
advanced sections for generation-time and backend-load-time parameters.
|
||||
Implemented behavior:
|
||||
|
||||
## Proposed Shape
|
||||
- `qwen3.8.reasoning_effort` accepts `xhigh`, `medium`, or `low`, defaults to
|
||||
`medium`, and is applied as a node-owned system instruction only when
|
||||
Qwen3.8 thinking is enabled.
|
||||
- Qwen3.8 is an independent config family and never falls back to the
|
||||
`qwen3.5` entry.
|
||||
- `qwen3.8.official_sampling_override` and
|
||||
`gemma4.official_sampling_override` default to `false`. When enabled, the
|
||||
selected model's documented recommendation is overlaid after model-family
|
||||
detection and before normal generation.
|
||||
- Qwen3.8 chooses its official profile from `enable_thinking`; Gemma 4 replaces
|
||||
only the three parameters present in its documented recommendation.
|
||||
- Dialogue Cycle resolves official sampling independently for model A and model
|
||||
B. Summary generation and KV-cache prompt signatures are unchanged.
|
||||
- `advanced_generation_kwargs.image_max_pixels` sets the IMAGE downscale limit
|
||||
for Session Chat (Simple). It is node-side preprocessing, so it is read
|
||||
separately and never forwarded to the backend with the other generation
|
||||
kwargs. It defaults to `262144`, is clamped to `65536`-`4194304`, and falls
|
||||
back to the default with a warning when it is not an integer. The value is
|
||||
threaded to `build_chat_messages()`; Dialogue Cycle does not read it. KV state
|
||||
is unaffected because KV restore and save are skipped for media turns.
|
||||
- Per-image vision token limits are not Simple config keys. Gemma 4 uses a
|
||||
fixed `image_max_tokens: 512` chat-handler kwarg, so `image_max_pixels` is the
|
||||
single user-facing image detail control. See `model-specific-parameter-flow.md`.
|
||||
- `advanced_backend_kwargs` accepts `n_batch` and `n_ubatch` as JSON integers
|
||||
and passes explicitly configured values to `Llama(...)` through
|
||||
`GGUFModelManager.load_model()` for both Simple nodes; Dialogue Cycle uses
|
||||
the same values for model A and model B. Values below `512` are raised to
|
||||
`512` because Gemma 4 `image_max_tokens` is fixed at 512. Non-integer values
|
||||
are omitted with a warning, and the node warns when the backend will limit
|
||||
`n_ubatch` to `n_ctx` or `n_batch`.
|
||||
- Backend kwargs are part of the model-load signature, so changing them reloads
|
||||
the model. They are not part of the history model signature, the KV-state
|
||||
signature, or the disk-cache key, and they are never forwarded to generation.
|
||||
- `advanced_backend_kwargs.verbosity` accepts JSON integers `0`-`5` and
|
||||
`advanced_backend_kwargs.logits_all` accepts JSON booleans; other values are
|
||||
omitted with a warning. When unset, `load_model()` keeps `verbose=False` and
|
||||
`logits_all=True` for Vision loads only. An explicit `logits_all` applies to
|
||||
Vision and text-only loads.
|
||||
- No unsupported-keyword fallback exists for `n_batch`, `n_ubatch`, or
|
||||
`logits_all`; they are long-standing `Llama(...)` arguments. `verbosity` is
|
||||
JamePeng-specific, so an exact unexpected-keyword `TypeError` naming it
|
||||
retries the load without it and logs the fallback. Other `TypeError` cases
|
||||
are not swallowed.
|
||||
- Explicit backend kwargs are recorded in each turn's history `params` as
|
||||
`advanced_backend_kwargs`.
|
||||
|
||||
The proposed split was:
|
||||
- `advanced_generation_kwargs` accepts `seed`, `top_k`, `min_p`, and
|
||||
`present_penalty` from Simple config and passes explicitly configured values
|
||||
to normal generation.
|
||||
- `advanced_summary_generation_kwargs.seed` is read separately and passed to
|
||||
summary generation.
|
||||
- Summary generation does not inherit normal advanced generation kwargs
|
||||
implicitly.
|
||||
- Missing, `null`, or invalid values are omitted. The node does not inject
|
||||
backend defaults for omitted advanced generation kwargs.
|
||||
- `top_k` accepts integers greater than or equal to zero, `min_p` accepts
|
||||
`0.0` through `1.0`, and `present_penalty` accepts `0.0` through `2.0`.
|
||||
- Other advanced keys in the example are not read yet. They are ignored, with a
|
||||
warning when `log_level` is not `minimal`.
|
||||
- Explicit advanced kwargs accepted by node validation are recorded in each
|
||||
turn's history `params`; backend defaults are not synthesized into history.
|
||||
A value remains recorded if runtime compatibility fallback later removes its
|
||||
rejected keyword.
|
||||
- If a backend raises an exact unexpected-keyword `TypeError` for `top_k`,
|
||||
`min_p`, or `present_penalty`, generation retries without only the rejected
|
||||
key and logs the compatibility fallback. Other `TypeError` cases retain the
|
||||
existing behavior and are not swallowed.
|
||||
|
||||
For strict reproducibility with `advanced_generation_kwargs.seed`, use
|
||||
`runtime_cache: "off"`. Real-machine testing showed that `LlamaTrieCache` can
|
||||
change deterministic output even when the same seed, prompt, model, and media
|
||||
are used. Keeping the loaded model instance is not the issue by itself; the
|
||||
runtime cache mode is the relevant difference.
|
||||
|
||||
## Candidate Config Shape
|
||||
|
||||
`config/simple_advanced.example.json` is the staging place for future advanced
|
||||
Simple config ideas. It may include the normal `simple_defaults.json` settings
|
||||
plus optional advanced sections for generation-time and backend-load-time
|
||||
parameters.
|
||||
|
||||
The intended top-level split is:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": null,
|
||||
"top_k": null,
|
||||
"min_p": null,
|
||||
"top_k": 40,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
"typical_p": null,
|
||||
"mirostat_mode": null,
|
||||
"mirostat_tau": null,
|
||||
@@ -52,68 +130,143 @@ The proposed split was:
|
||||
"typical_p": null
|
||||
},
|
||||
"advanced_backend_kwargs": {
|
||||
"n_batch": null,
|
||||
"n_ubatch": null,
|
||||
"verbosity": null,
|
||||
"logits_all": null,
|
||||
"ctx_checkpoints": null,
|
||||
"checkpoint_on_device": null,
|
||||
"verbosity": null,
|
||||
"log_filters": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`advanced_generation_kwargs` would be for `create_completion()` /
|
||||
`create_chat_completion()` options such as `seed` and `top_k`.
|
||||
Section meanings:
|
||||
|
||||
`advanced_summary_generation_kwargs` would be for summary-generation options
|
||||
needed for reproducibility. Summary generation does not inherit
|
||||
`advanced_generation_kwargs` implicitly. If a user wants fixed summary sampling,
|
||||
they should set `advanced_summary_generation_kwargs` explicitly. If omitted,
|
||||
summaries should continue using the normal summary settings.
|
||||
- `advanced_generation_kwargs`: runtime generation options for
|
||||
`create_completion()` / `create_chat_completion()`, such as `seed` and
|
||||
sampling controls. `image_max_pixels` is the exception: it controls
|
||||
node-side IMAGE downscaling and is not forwarded.
|
||||
- `advanced_summary_generation_kwargs`: summary-generation options needed for
|
||||
reproducibility or future summary-specific sampling behavior.
|
||||
- `advanced_backend_kwargs`: model-load/backend options passed to `Llama(...)`,
|
||||
such as batch sizes, checkpoint, or backend logging controls.
|
||||
|
||||
`advanced_backend_kwargs` would be for model-load/backend options passed to `Llama(...)`,
|
||||
such as checkpoint or backend logging controls.
|
||||
Example-only keys are not supported until they are explicitly read, validated,
|
||||
tested, and documented.
|
||||
|
||||
## Current Decision
|
||||
## Design Rules For Adding Advanced Parameters
|
||||
|
||||
Implement only the narrow seed path for now.
|
||||
Add a new advanced parameter only when all of these are true:
|
||||
|
||||
1. There is a concrete maintainer or user need.
|
||||
2. The target runtime API and call path are known.
|
||||
3. Backend-version, model-family, and device-dependent behavior is understood
|
||||
enough to document the risk.
|
||||
4. The parameter can be allowlisted rather than passed through broadly.
|
||||
5. Invalid values can be omitted or rejected predictably.
|
||||
6. Tests can prove that the Simple config value reaches the intended runtime
|
||||
path.
|
||||
|
||||
Do not add broad pass-through surfaces only because the backend accepts arbitrary
|
||||
kwargs. A broad surface increases issue triage cost and can make backend-specific
|
||||
failures look like node bugs.
|
||||
|
||||
## Validation And Compatibility Rules
|
||||
|
||||
Use an explicit allowlist for each advanced section.
|
||||
|
||||
For each supported key:
|
||||
|
||||
- Validate type.
|
||||
- Clamp numeric values where a safe range is known.
|
||||
- Omit `null` values.
|
||||
- Omit missing values.
|
||||
- Treat invalid values as omitted unless there is a strong reason to fail fast.
|
||||
- Warn about unsupported keys when `log_level` is not `minimal`.
|
||||
- Keep warning text clear that unsupported example keys are intentionally ignored.
|
||||
|
||||
Prefer narrow, allowlisted parsers over generic dictionary forwarding. Generic
|
||||
forwarding should be considered only after enough keys share the same validation,
|
||||
warning, history, and fallback behavior.
|
||||
|
||||
## Summary Generation Rules
|
||||
|
||||
Summary generation has its own advanced section.
|
||||
|
||||
Do not implicitly copy normal generation options into summary generation. If a
|
||||
user wants fixed summary sampling or other summary-specific behavior, they should
|
||||
set `advanced_summary_generation_kwargs` explicitly.
|
||||
|
||||
When adding summary advanced parameters:
|
||||
|
||||
- Preserve current summary defaults unless the key is explicitly configured.
|
||||
- Test normal generation and summary generation separately.
|
||||
- Record applied summary advanced kwargs separately in history params.
|
||||
- Update `ADVANCED_PARAMETERS.md` with summary-specific usage notes.
|
||||
|
||||
## Backend Fallback Rules
|
||||
|
||||
Backend kwargs are especially version-, build-, model-, and device-dependent.
|
||||
|
||||
If broader backend kwargs are added later, targeted fallback should be considered
|
||||
only for clearly identified unsupported-keyword `TypeError` cases. Avoid catching
|
||||
all backend failures and retrying with silently reduced options, because that can
|
||||
hide real model-load or generation errors.
|
||||
|
||||
When fallback is implemented:
|
||||
|
||||
- Log or warn which key was removed unless `log_level` suppresses it.
|
||||
- Retry only when the error clearly identifies an unsupported keyword.
|
||||
- Preserve the original error if fallback also fails.
|
||||
- Add regression tests for both supported and unsupported backend versions where
|
||||
practical.
|
||||
|
||||
## History Recording Rules
|
||||
|
||||
Applied advanced kwargs should be recorded in each turn's history `params`.
|
||||
|
||||
History records should show only explicitly configured values accepted by node
|
||||
validation and sent toward the runtime path. Invalid, missing, `null`, or
|
||||
unsupported keys should not appear as applied parameters. A compatibility
|
||||
fallback may remove a rejected key for the successful retry without rewriting
|
||||
the already assembled requested-parameter record.
|
||||
|
||||
When an official sampling profile is active, history records the effective
|
||||
overridden values and `official_sampling_profile`. This is runtime metadata and
|
||||
must not be forwarded to the generation backend.
|
||||
|
||||
When a parameter affects summary generation, record it in a summary-specific
|
||||
history field or clearly separated summary advanced kwargs entry rather than
|
||||
making it look like a normal generation parameter.
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
When a new advanced parameter becomes supported:
|
||||
|
||||
- Update `ADVANCED_PARAMETERS.md` with user-facing usage, examples, limitations,
|
||||
and reproducibility notes.
|
||||
- Update `config/simple_advanced.example.json` if it should appear in the
|
||||
example config.
|
||||
- Update this file if the new parameter changes implementation policy,
|
||||
validation policy, fallback behavior, or supported advanced sections.
|
||||
- Add or update tests proving the Simple config value reaches the intended
|
||||
runtime path.
|
||||
|
||||
Do not document a parameter as supported in `ADVANCED_PARAMETERS.md` until it is
|
||||
implemented, validated, and tested.
|
||||
|
||||
## Initial Seed-Only Decision
|
||||
|
||||
The first implemented advanced Simple config path intentionally supported only
|
||||
`seed`. It was later expanded narrowly with `top_k`, `min_p`, and
|
||||
`present_penalty` for documented Qwen and Gemma sampling recommendations.
|
||||
|
||||
Reasons:
|
||||
|
||||
- The maintainer does not currently need these advanced parameters.
|
||||
- Many of these options are backend-version, model, and device dependent.
|
||||
- A broad pass-through surface may increase issue triage and support cost.
|
||||
- The maintainer did not need the other advanced parameters at the time.
|
||||
- Many advanced options are backend-version, model, and device dependent.
|
||||
- A broad pass-through surface would increase issue triage and support cost.
|
||||
|
||||
Implemented behavior:
|
||||
|
||||
- `advanced_generation_kwargs.seed` is read from Simple config and passed to
|
||||
normal generation.
|
||||
- `advanced_summary_generation_kwargs.seed` is read separately and passed to
|
||||
summary generation.
|
||||
- Summary generation does not inherit `advanced_generation_kwargs.seed`
|
||||
implicitly.
|
||||
- Missing, `null`, or invalid seed values are omitted.
|
||||
- Other advanced keys in the example are not read yet. They are ignored, with a
|
||||
warning when `log_level` is not `minimal`.
|
||||
- Applied advanced kwargs are recorded in each turn's history `params`.
|
||||
- For strict reproducibility with `advanced_generation_kwargs.seed`, use
|
||||
`runtime_cache: "off"`. Real-machine testing showed that `LlamaTrieCache` can
|
||||
change deterministic output even when the same seed, prompt, model, and image
|
||||
are used. Keeping the loaded model instance is not the issue by itself; the
|
||||
runtime cache mode is the relevant difference.
|
||||
- Unsupported backend-kwarg fallback is not implemented for the seed-only path.
|
||||
If broader kwargs are added later, targeted fallback should be considered only
|
||||
for clearly identified unsupported-keyword `TypeError` cases.
|
||||
|
||||
## If Revisited
|
||||
|
||||
Prefer starting with a narrow feature instead of opening a broad pass-through.
|
||||
|
||||
The next candidates should be added only when there is a clear use case and a
|
||||
focused validation path.
|
||||
|
||||
For further additions, use:
|
||||
|
||||
- an allowlist of supported keys
|
||||
- type validation and clamping where appropriate
|
||||
- omission of `null` values
|
||||
- tests proving that Simple config values reach the intended runtime path
|
||||
- documentation that the feature is advanced and backend-dependent
|
||||
This remains the preferred expansion pattern: add one narrow, well-tested
|
||||
parameter or parameter group at a time instead of opening a broad pass-through.
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
# Agent Development Plan
|
||||
|
||||
- Status: Proposed
|
||||
- Last reviewed: 2026-07-11
|
||||
- Source: Adapted from `experiments/plan.md`
|
||||
- Scope: Codex Extension and similar coding agents working on this repository.
|
||||
- Update when: Agent workflow, testing policy, documentation routing, or release verification expectations change.
|
||||
|
||||
This document defines the repository-level plan for making AI-assisted
|
||||
development repeatable, reviewable, and safe for ComfyUI-LLM-Session.
|
||||
|
||||
The goal is not to replace human ComfyUI validation. The goal is to make every
|
||||
agent-assisted change arrive with enough repository context, focused tests, and
|
||||
documentation discipline that manual ComfyUI checks can focus on behavior that
|
||||
pytest cannot prove.
|
||||
|
||||
## Objectives
|
||||
|
||||
- Improve implementation quality for agent-assisted changes.
|
||||
- Avoid repeating the same project instructions in every prompt.
|
||||
- Give coding agents a stable entry point for repository rules.
|
||||
- Maximize the behavior covered by pytest.
|
||||
- Keep the boundary between automated pytest checks and manual ComfyUI checks
|
||||
explicit.
|
||||
- Preserve project knowledge in tracked documentation instead of ignored
|
||||
experiment files.
|
||||
|
||||
## Operating Model
|
||||
|
||||
Agents are expected to:
|
||||
|
||||
- Understand the repository structure before editing.
|
||||
- Prepare or infer an implementation plan for non-trivial changes.
|
||||
- Keep edits scoped to the requested behavior.
|
||||
- Add or update tests when behavior changes.
|
||||
- Run focused pytest checks, and preferably the full suite before completion.
|
||||
- Fix failing tests caused by the change before reporting completion.
|
||||
- State any manual ComfyUI validation that remains out of scope for the agent.
|
||||
|
||||
Manual ComfyUI validation remains the final quality gate for UI/runtime behavior
|
||||
that cannot be fully exercised in pytest.
|
||||
|
||||
In short:
|
||||
|
||||
```text
|
||||
pytest = automated guardrail
|
||||
ComfyUI manual validation = final behavior check
|
||||
```
|
||||
|
||||
## Current Repository Context
|
||||
|
||||
The repository already has several durable documents that should be reused
|
||||
instead of duplicated:
|
||||
|
||||
- `docs/index.md`: documentation map and routing.
|
||||
- `docs/testing-rules.md`: canonical testing guidance.
|
||||
- `docs/refactoring-rules.md`: refactoring boundaries and stop criteria.
|
||||
- `docs/logging-guidelines.md`: canonical logging and exception-handling rules.
|
||||
- `docs/change-documentation-guidelines.md`: documentation update expectations.
|
||||
- `docs/architecture.md`: service/module architecture overview.
|
||||
|
||||
The repository also already has a meaningful pytest suite. Because of that, this
|
||||
plan does not require an immediate physical test directory migration. The safer
|
||||
near-term path is to strengthen naming, coverage, and documentation around the
|
||||
existing `tests/core`, `tests/services`, and root-level focused tests.
|
||||
|
||||
## Priority 1: Stable Agent Entry Point
|
||||
|
||||
### 1. Add `AGENTS.md`
|
||||
|
||||
Status: Done.
|
||||
|
||||
Create a tracked `AGENTS.md` at the repository root. This should be the first
|
||||
file a coding agent reads before making changes.
|
||||
|
||||
`AGENTS.md` should stay concise and route agents to the detailed docs instead of
|
||||
copying all rules inline.
|
||||
|
||||
Recommended contents:
|
||||
|
||||
- Repository purpose and major modules.
|
||||
- Required document reading order.
|
||||
- Implementation planning expectations.
|
||||
- Testing expectations.
|
||||
- Documentation update expectations.
|
||||
- Definition of Done.
|
||||
- Manual ComfyUI validation boundary.
|
||||
|
||||
`AGENTS.md` should complement `docs/index.md`; it should not replace the docs
|
||||
index. The intended split is:
|
||||
|
||||
- `AGENTS.md`: mandatory agent operating rules.
|
||||
- `docs/index.md`: map of maintained project documentation.
|
||||
|
||||
### 2. Define Documentation Routing
|
||||
|
||||
Status: Done.
|
||||
|
||||
Use `docs/index.md` as the human-readable documentation map, and make
|
||||
`AGENTS.md` the agent-readable entry point.
|
||||
|
||||
The expected routing is:
|
||||
|
||||
- General code change: read `AGENTS.md`, then `docs/index.md`.
|
||||
- Refactoring: also read `docs/refactoring-rules.md`.
|
||||
- Tests: also read `docs/testing-rules.md`.
|
||||
- Logging or exception handling: also read `docs/logging-guidelines.md`.
|
||||
- Public behavior, parameters, or release notes: also read
|
||||
`docs/change-documentation-guidelines.md`.
|
||||
- Architecture-sensitive work: also read `docs/architecture.md`.
|
||||
|
||||
### 3. Add an Implementation Plan Template
|
||||
|
||||
Status: Not started.
|
||||
|
||||
Add `docs/templates/implementation-plan.md` only if repeated planning work
|
||||
starts to drift. Until then, a short plan in the task thread is acceptable.
|
||||
|
||||
The template should include:
|
||||
|
||||
- Goal
|
||||
- Design
|
||||
- Impact
|
||||
- Risks
|
||||
- Verification Plan
|
||||
- Docs Compliance
|
||||
- Definition of Done
|
||||
|
||||
The template should be lightweight. It should help agents think clearly without
|
||||
turning small fixes into paperwork.
|
||||
|
||||
### 4. Add a Verification Plan Template
|
||||
|
||||
Status: Done.
|
||||
|
||||
Add `docs/templates/verification-plan.md`.
|
||||
|
||||
This is higher priority than the implementation template because verification
|
||||
quality directly affects whether agent-assisted changes are safe to merge.
|
||||
|
||||
Recommended sections:
|
||||
|
||||
- Automated Tests
|
||||
- Regression Tests
|
||||
- Contract Tests
|
||||
- Manual ComfyUI Tests
|
||||
- Out of Scope
|
||||
|
||||
## Priority 2: Testing Discipline
|
||||
|
||||
### 5. Clarify the Role of Pytest
|
||||
|
||||
Status: Done.
|
||||
|
||||
`docs/testing-rules.md` is the canonical place for pytest policy.
|
||||
|
||||
Pytest should cover:
|
||||
|
||||
- Pure functions and data transformations.
|
||||
- Defaults and parameter contracts.
|
||||
- Model family detection and model-specific behavior.
|
||||
- Message construction.
|
||||
- Runtime container wiring.
|
||||
- Service-level orchestration.
|
||||
- Regression cases for previously fixed bugs.
|
||||
|
||||
Pytest should not pretend to fully replace:
|
||||
|
||||
- ComfyUI node graph execution in a real UI session.
|
||||
- GPU/runtime-specific behavior.
|
||||
- llama.cpp backend compatibility across all local installations.
|
||||
- Visual inspection or user workflow validation.
|
||||
|
||||
### 6. Preserve Current Test Layout for Now
|
||||
|
||||
Status: Done.
|
||||
|
||||
Do not immediately move tests into `tests/unit`, `tests/integration`,
|
||||
`tests/regression`, and `tests/contract`.
|
||||
|
||||
The current layout already has useful locality:
|
||||
|
||||
- `tests/core`
|
||||
- `tests/services`
|
||||
- focused root-level tests such as defaults, model family aliases, media input,
|
||||
runtime container integration, and tensor split behavior
|
||||
|
||||
For now, improve test intent through:
|
||||
|
||||
- Clear test names.
|
||||
- Regression-oriented names for bug fixes.
|
||||
- Focused helper modules when setup becomes repetitive.
|
||||
- References in `docs/testing-rules.md`.
|
||||
|
||||
Physical directories such as `tests/regression` or `tests/contract` should be
|
||||
introduced only when there are enough tests of that type to make the new
|
||||
structure easier to navigate.
|
||||
|
||||
### 7. Prioritize Regression Tests
|
||||
|
||||
Status: Ongoing.
|
||||
|
||||
When fixing a bug, add a regression test whenever the behavior can be expressed
|
||||
without launching ComfyUI.
|
||||
|
||||
Regression tests should prefer names that describe the behavior being preserved,
|
||||
for example:
|
||||
|
||||
```text
|
||||
test_summary_override_does_not_leak_to_normal_generation
|
||||
test_model_family_alias_accepts_known_variants
|
||||
test_kv_state_is_not_saved_when_generation_fails
|
||||
```
|
||||
|
||||
### 8. Expand Contract Tests Carefully
|
||||
|
||||
Status: Ongoing.
|
||||
|
||||
Contract tests are valuable for this project because ComfyUI node classes expose
|
||||
structured interfaces that can break even when implementation details still
|
||||
work.
|
||||
|
||||
Useful contract test targets include:
|
||||
|
||||
- `INPUT_TYPES`
|
||||
- `RETURN_TYPES`
|
||||
- `RETURN_NAMES`
|
||||
- node class mappings
|
||||
- default values shared between simple/full nodes
|
||||
- service request/response dataclasses
|
||||
- model-specific parameter routing
|
||||
|
||||
Contract tests should avoid overfitting to private implementation details unless
|
||||
the private helper is intentionally stable and covered by `docs/testing-rules.md`.
|
||||
|
||||
## Priority 3: Manual Validation and Automation
|
||||
|
||||
### 9. Add a Manual ComfyUI Test Checklist
|
||||
|
||||
Status: Done.
|
||||
|
||||
Add `docs/manual-test-checklist.md` after the agent entry point and verification
|
||||
template are in place.
|
||||
|
||||
The checklist should focus on behavior pytest cannot fully prove:
|
||||
|
||||
- Node appears in ComfyUI.
|
||||
- Basic Session Chat generation works.
|
||||
- Dialogue Cycle flow works.
|
||||
- Continue/summary behavior works.
|
||||
- KV cache behavior works.
|
||||
- GGUF discovery works.
|
||||
- Image-capable model path works when available.
|
||||
- Errors are understandable in the UI/log output.
|
||||
|
||||
### 10. Add a Docs Compliance Checklist
|
||||
|
||||
Status: Partially done.
|
||||
|
||||
This can be a standalone document or a section of the implementation/verification
|
||||
template. A documentation check section now exists in
|
||||
`docs/templates/verification-plan.md`; a standalone checklist has not been added.
|
||||
|
||||
It should ask whether the change requires updates to:
|
||||
|
||||
- `README.md`
|
||||
- `README.ja.md`
|
||||
- `PARAMETERS.md`, if present or reintroduced
|
||||
- `CHANGELOG.md`
|
||||
- `docs/architecture.md`
|
||||
- `docs/testing-rules.md`
|
||||
- `docs/refactoring-rules.md`
|
||||
- `docs/logging-guidelines.md`
|
||||
- `docs/change-documentation-guidelines.md`
|
||||
|
||||
### 11. Defer `agent-check.ps1`
|
||||
|
||||
Status: Deferred.
|
||||
|
||||
An `agent-check.ps1` helper may be useful later, but it should come after the
|
||||
workflow is stable.
|
||||
|
||||
Possible future checks:
|
||||
|
||||
- Run pytest.
|
||||
- Check that `AGENTS.md` exists.
|
||||
- Check that docs required by `docs/index.md` exist.
|
||||
- Check obvious stale references.
|
||||
- Check whether changed public behavior has a changelog or documentation update.
|
||||
|
||||
Do not add this script before the manual process has settled. A premature script
|
||||
can make the workflow look automated while still missing the important review
|
||||
questions.
|
||||
|
||||
Before starting `agent-check.ps1`, complete the following:
|
||||
|
||||
- Use `AGENTS.md` in several real coding tasks and note repeated omissions.
|
||||
- Use `docs/templates/verification-plan.md` for changes that need explicit
|
||||
verification planning.
|
||||
- Use `docs/manual-test-checklist.md` for at least one manual ComfyUI validation
|
||||
flow and confirm the routing matrix is practical.
|
||||
- Decide whether docs compliance belongs only in the verification template or
|
||||
also needs a standalone checklist.
|
||||
- Identify checks that are deterministic enough to automate, such as required
|
||||
file existence, docs index coverage, stale references, and pytest execution.
|
||||
- Keep manual ComfyUI validation as a reported human activity; do not try to
|
||||
mark it passed from a script.
|
||||
|
||||
### 12. Define the Definition of Done
|
||||
|
||||
Status: Done.
|
||||
|
||||
Add the final Definition of Done to `AGENTS.md`, then mirror or reference it from
|
||||
templates.
|
||||
|
||||
Recommended baseline:
|
||||
|
||||
- Implementation is complete and scoped.
|
||||
- Relevant tests are added or updated.
|
||||
- Focused pytest checks pass.
|
||||
- Full pytest suite passes when practical.
|
||||
- Documentation impact is reviewed.
|
||||
- Manual ComfyUI validation needs are stated.
|
||||
- Known limitations or out-of-scope items are reported.
|
||||
|
||||
## Future Work
|
||||
|
||||
### Strengthen the Test Harness
|
||||
|
||||
Possible future harness improvements:
|
||||
|
||||
- lightweight fake manager
|
||||
- fake llama model
|
||||
- fake ComfyUI node execution context
|
||||
- reusable service fixtures
|
||||
- test utilities for media inputs and KV state
|
||||
|
||||
These should be introduced incrementally when they reduce duplication or make
|
||||
high-value behavior testable.
|
||||
|
||||
### Improve Agent Knowledge Organization
|
||||
|
||||
As the repository grows, consider keeping `AGENTS.md` short and moving deeper
|
||||
agent guidance into focused docs. The rule should be:
|
||||
|
||||
- `AGENTS.md` tells agents what to read and what must be true before completion.
|
||||
- `docs/*` explains the detailed policy.
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
1. Create `AGENTS.md`.
|
||||
2. Create `docs/templates/verification-plan.md`.
|
||||
3. Add the Definition of Done to `AGENTS.md`.
|
||||
4. Add `docs/manual-test-checklist.md`.
|
||||
5. Add or extend docs compliance guidance.
|
||||
6. Revisit whether an implementation template is needed.
|
||||
7. Revisit whether `agent-check.ps1` is worth adding.
|
||||
8. Revisit physical test directory splitting only if the current layout becomes
|
||||
hard to navigate.
|
||||
|
||||
## Final Target State
|
||||
|
||||
The desired workflow is:
|
||||
|
||||
1. The agent reads `AGENTS.md`.
|
||||
2. The agent follows documentation routing through `docs/index.md`.
|
||||
3. The agent prepares an appropriately sized implementation plan.
|
||||
4. The agent implements the change.
|
||||
5. The agent adds or updates relevant tests.
|
||||
6. The agent runs pytest.
|
||||
7. The agent reviews documentation impact.
|
||||
8. The human performs any necessary ComfyUI validation.
|
||||
9. The change is reported with test results, documentation notes, and remaining
|
||||
manual validation needs.
|
||||
|
||||
This keeps agent-assisted development fast without making it casual.
|
||||
+133
-74
@@ -1,114 +1,173 @@
|
||||
# Architecture Overview
|
||||
|
||||
This document describes the module layering and dependency direction for the refactored LLM Session node implementation.
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Module boundaries, dependency direction, or tracked top-level directories change.
|
||||
|
||||
## Layer Roles
|
||||
- `llm_session_nodes.py`: ComfyUI node entry points, UI/input wiring, and runtime dependency assembly.
|
||||
This module is also the composition root where request/dependency objects are assembled for services.
|
||||
- `core/`: Reusable pure-ish logic (prompt rewrite, KV signature/load/save flow, generation retry loop, shared result types, centralized defaults, runtime container).
|
||||
- `services/`: Orchestration logic that coordinates multi-step application flows (including node-execution request/dependency orchestration services).
|
||||
- `infra/`: Side-effect helpers for filesystem/path persistence.
|
||||
- `tests/`: Refactoring safety-net tests that lock current behavior for core and service orchestration paths.
|
||||
This document describes the repository structure, module layering, and dependency
|
||||
direction for the LLM Session node implementation.
|
||||
|
||||
Generated files, local editor settings, virtual environments, caches, and other
|
||||
`.gitignore`-ignored directories are intentionally out of scope here. This
|
||||
document focuses on source, tests, documentation, examples, and packaged assets
|
||||
that are part of the repository.
|
||||
|
||||
## Directory Roles
|
||||
|
||||
- `llm_session_nodes.py`: ComfyUI node entry points, UI/input wiring, model
|
||||
manager integration, compatibility glue, and runtime dependency assembly. This
|
||||
file is also the composition root where request/dependency objects are built
|
||||
for service-layer orchestration.
|
||||
- `core/`: Reusable pure-ish logic and shared runtime helpers. Code here should
|
||||
avoid ComfyUI imports, filesystem layout assumptions, and service-layer
|
||||
orchestration.
|
||||
- `services/`: Application orchestration logic for generation, history
|
||||
persistence, KV-state coordination, Session Chat turns, and Dialogue Cycle
|
||||
node execution.
|
||||
- `infra/`: Low-level side-effect helpers for history/transcript paths and
|
||||
filesystem persistence.
|
||||
- `config/`: Versioned default/example configuration files, including Simple
|
||||
node defaults and advanced Simple config examples.
|
||||
- `web/`: Frontend extension assets loaded by ComfyUI.
|
||||
- `docs/`: Maintainer-facing design notes, change rules, audits, known issues,
|
||||
and architecture documentation.
|
||||
- `examples/`: Example workflows or usage artifacts intended to be checked in.
|
||||
- `images/`: Documentation images and packaged visual assets.
|
||||
- `tests/`: Refactoring safety-net and regression tests that lock current
|
||||
behavior for node wiring, core helpers, services, config defaults, and
|
||||
compatibility paths.
|
||||
- `.github/`: GitHub repository metadata such as issue templates and workflows.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
Allowed:
|
||||
- `llm_session_nodes.py` -> `core/`, `services/`, `infra/`
|
||||
- `services/` -> `core/` (and injected callbacks)
|
||||
|
||||
- `llm_session_nodes.py` -> `core/`, `services/`, `infra/`, `config/`
|
||||
- `services/` -> `core/`, `infra/` only through explicit helpers or injected
|
||||
callbacks/dependencies
|
||||
- `core/` -> standard library only
|
||||
- `infra/` -> standard library only
|
||||
- `tests/` -> `llm_session_nodes.py`, `core/`, `services/`, `infra/`
|
||||
- `infra/` -> standard library only, except `core.logging_utils` as the current
|
||||
cross-cutting logging utility
|
||||
- If more cross-cutting utilities are needed by both `core/` and `infra/`,
|
||||
consider introducing a lower-level `common/` layer instead of adding more
|
||||
`infra/` -> `core/` exceptions.
|
||||
- `web/` -> browser/ComfyUI frontend APIs only
|
||||
- `tests/` -> production modules and test fixtures
|
||||
- `docs/`, `examples/`, `images/`, `.github/` -> no production runtime imports
|
||||
|
||||
Disallowed:
|
||||
- `core/` -> `services/` or node layer
|
||||
- `infra/` -> `services/` or node layer
|
||||
- production layers (`llm_session_nodes.py`, `core/`, `services/`, `infra/`) -> `tests/`
|
||||
|
||||
- `core/` -> `services/`, `infra/`, node layer, ComfyUI APIs, or repository
|
||||
path layout
|
||||
- `infra/` -> `core/`, `services/`, node layer, or ComfyUI APIs, except
|
||||
`core.logging_utils` as noted above
|
||||
- `services/` -> node layer or ComfyUI APIs unless passed in as callbacks from
|
||||
the composition root
|
||||
- production runtime layers (`llm_session_nodes.py`, `core/`, `services/`,
|
||||
`infra/`) -> `tests/`, `docs/`, `examples/`, `images/`, or `.github/`
|
||||
|
||||
## One-Screen Dependency Diagram
|
||||
|
||||
```text
|
||||
[ComfyUI Nodes / Entry]
|
||||
llm_session_nodes.py
|
||||
|
|
||||
| uses
|
||||
├--------------------------┐
|
||||
| |
|
||||
v v
|
||||
+-------------------+ +----------------------------+
|
||||
| core/ | | services/ |
|
||||
|-------------------| |----------------------------|
|
||||
| defaults | | chat_turn_service |
|
||||
| continue_rewrite |<-----| - ChatTurnService |
|
||||
| kv_state |<-----| - DialogueCycleNode |
|
||||
| generation_runner | | ExecutionService |
|
||||
| turn_types | | turn_execution_service |
|
||||
| runtime_container | | - TurnExecutionService |
|
||||
| | | - SessionChatNode |
|
||||
| | | ExecutionService |
|
||||
| | | generation_execution_ |
|
||||
| | | service |
|
||||
| | | - GenerationExecution |
|
||||
| | | Service |
|
||||
| | | kv_state_service |
|
||||
| | | - KvStateService |
|
||||
| | | history_persistence_ |
|
||||
| | | service |
|
||||
| | | - HistoryPersistence |
|
||||
| | | Service |
|
||||
+-------------------+ +----------------------------+
|
||||
| \
|
||||
| \ reads versioned defaults/examples
|
||||
| v
|
||||
| config/
|
||||
|
|
||||
| side effects via wrapper calls
|
||||
| builds requests/dependencies and injects callbacks
|
||||
v
|
||||
+-------------------+
|
||||
| infra/ |
|
||||
|-------------------|
|
||||
| history_store |
|
||||
+-------------------+
|
||||
+-------------------+ +----------------------------+
|
||||
| core/ |<-----| services/ |
|
||||
|-------------------| |----------------------------|
|
||||
| defaults | | generation_execution |
|
||||
| generation_runner | | turn_execution |
|
||||
| kv_state | | chat_turn |
|
||||
| logging_utils | | history_persistence |
|
||||
| runtime_container | | kv_state_service |
|
||||
| turn_types | +----------------------------+
|
||||
+-------------------+ |
|
||||
| filesystem side effects
|
||||
v
|
||||
+-------------------+
|
||||
| infra/ |
|
||||
|-------------------|
|
||||
| history_store |
|
||||
+-------------------+
|
||||
|
||||
External:
|
||||
Adjacent repository assets:
|
||||
- `web/`: ComfyUI frontend extension assets.
|
||||
- `docs/`, `examples/`, `images/`, `.github/`: documentation, examples,
|
||||
packaged assets, and repository metadata.
|
||||
|
||||
External runtime dependencies:
|
||||
- llama-cpp-python (generation/cache backend)
|
||||
- folder_paths (ComfyUI path resolution)
|
||||
- comfy.model_management interrupt APIs (injected from the node composition root)
|
||||
- filesystem (history json, transcript txt, cache dir)
|
||||
- filesystem (history JSON and transcript TXT persistence)
|
||||
```
|
||||
|
||||
## Key Module Summary
|
||||
- `core/defaults.py`: centralized behavior-preserving defaults for node config, UI defaults, and option labels.
|
||||
|
||||
- `core/defaults.py`: centralized behavior-preserving defaults for node config,
|
||||
UI defaults, and option labels.
|
||||
- `core/turn_types.py`: shared dataclass result types.
|
||||
- `core/continue_rewrite.py`: language-aware rewrite of `continue` prompts.
|
||||
- `core/kv_state.py`: KV state signature/build/restore/save helpers.
|
||||
- `core/generation_runner.py`: shared generation retry, fallback flow, and injected interrupt/abort handling.
|
||||
- `core/runtime_container.py`: runtime-scoped container for injected session-chat manager, dialogue-cycle manager pool, and in-memory KV state.
|
||||
- `core/generation_runner.py`: shared generation retry, fallback flow, and
|
||||
injected interrupt/abort handling.
|
||||
- `core/logging_utils.py`: centralized logging helpers and safe exception
|
||||
logging utilities.
|
||||
- `core/runtime_container.py`: runtime-scoped container for injected Session Chat
|
||||
manager, Dialogue Cycle manager pool, and in-memory KV state.
|
||||
- `services/generation_execution_service.py`: generation execution wrapper that
|
||||
owns adaptive context-overflow retry classification.
|
||||
- `services/turn_execution_service.py`: Session Chat turn orchestration.
|
||||
- `services/chat_turn_service.py`: Dialogue Cycle request/dependency types and
|
||||
node-execution orchestration.
|
||||
- `services/history_persistence_service.py`: history save/load coordination used
|
||||
by service orchestration.
|
||||
- `services/kv_state_service.py`: service-level KV-state restore/save
|
||||
coordination around generation.
|
||||
- `infra/history_store.py`: history/transcript path and file I/O helpers.
|
||||
- `llm_session_nodes.py` runtime behavior:
|
||||
- Default runtime container is lazily initialized via resolver helpers.
|
||||
- Dialogue-cycle managers are resolved from runtime container slots (`A`/`B`) so they can persist across runs.
|
||||
- Chat handler classes are tracked in an internal registry map (not `globals()` mutation).
|
||||
- Chat-format-specific UI overrides such as `enable_thinking` are assembled near the chat handler/text builder config maps before entering service orchestration.
|
||||
- ComfyUI interrupt/cancel callbacks are assembled here and injected into the generation runner so `core/` remains independent of ComfyUI imports.
|
||||
- `services/chat_turn_service.py`: dialogue-cycle orchestration and node-execution orchestration (`DialogueCycleNodeExecutionService`).
|
||||
- `services/turn_execution_service.py`: thin orchestration for shared single-turn execution and node-entry invocation (`SessionChatNodeExecutionService`).
|
||||
- `services/generation_execution_service.py`: adaptive generation orchestration and assistant output normalization.
|
||||
- `services/kv_state_service.py`: KV cache restore/save orchestration and mismatch recovery handling.
|
||||
- `services/history_persistence_service.py`: turn append, optional summarization, and history JSON persistence.
|
||||
- `infra/history_store.py`: history/transcript path and file I/O helpers.
|
||||
- Dialogue Cycle managers are resolved from runtime container slots (`A`/`B`)
|
||||
so they can persist across runs.
|
||||
- Chat handler classes are tracked in an internal registry map rather than by
|
||||
mutating `globals()`.
|
||||
- Chat-format-specific UI overrides such as `enable_thinking` are assembled
|
||||
near the chat handler/text builder config maps before entering service
|
||||
orchestration.
|
||||
- ComfyUI interrupt/cancel callbacks are assembled here and injected into the
|
||||
generation runner so `core/` remains independent of ComfyUI imports.
|
||||
|
||||
## Parameter Precedence
|
||||
Parameters may come from multiple layers: built-in model-family defaults, Full-node UI inputs,
|
||||
Simple-node defaults, and explicit per-model overrides in `config/simple_defaults.json`.
|
||||
|
||||
Parameters may come from multiple layers: built-in model-family defaults,
|
||||
Full-node UI inputs, Simple-node defaults, and explicit per-model overrides in
|
||||
`config/simple_defaults.json`.
|
||||
|
||||
When these layers overlap, the intended precedence is:
|
||||
|
||||
1. Explicit per-model config overrides, such as `gemma4.enable_thinking` in `config/simple_defaults.json`
|
||||
1. Explicit per-model config overrides, such as `gemma4.enable_thinking` in
|
||||
`config/simple_defaults.json`
|
||||
2. Explicit Full-node UI input values
|
||||
3. Simple built-in defaults / Full UI defaults
|
||||
4. Model-family fallback maps such as `CHAT_HANDLER_KWARGS_MAP`, `TEXT_CHAT_BUILDER_CONFIG_MAP`, and `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`
|
||||
4. Model-family fallback maps such as `CHAT_HANDLER_KWARGS_MAP`,
|
||||
`TEXT_CHAT_BUILDER_CONFIG_MAP`, and `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`
|
||||
|
||||
Simple nodes may delegate to Full-node methods for compatibility, but a Full-node default argument
|
||||
must not overwrite an explicit Simple config override. Merge helpers that apply UI defaults should
|
||||
preserve existing per-model override values and only fill missing keys.
|
||||
Simple nodes may delegate to Full-node methods for compatibility, but a
|
||||
Full-node default argument must not overwrite an explicit Simple config override.
|
||||
Merge helpers that apply UI defaults should preserve existing per-model override
|
||||
values and only fill missing keys.
|
||||
|
||||
For model-family-specific parameters, prefer a named helper that makes this rule visible in code,
|
||||
for example a helper whose name includes `preserving_explicit_overrides`. Add a regression test
|
||||
that proves a Simple config value wins over a Full-node default before adding or changing such
|
||||
merge behavior.
|
||||
For model-family-specific parameters, prefer a named helper that makes this rule
|
||||
visible in code, for example a helper whose name includes
|
||||
`preserving_explicit_overrides`. Add a regression test that proves a Simple
|
||||
config value wins over a Full-node default before adding or changing such merge
|
||||
behavior.
|
||||
|
||||
For detailed wiring of `CHAT_HANDLER_KWARGS_MAP`, `TEXT_CHAT_BUILDER_CONFIG_MAP`,
|
||||
and `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`, see
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
# Change Documentation Guidelines
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-11
|
||||
- Update when: Documentation roles, commit-message rules, release-note rules, or docs metadata policy changes.
|
||||
|
||||
This document defines how to document and describe changes in this repository.
|
||||
Use it when adding features, changing behavior, updating compatibility notes,
|
||||
writing commit messages, preparing changelog entries, or drafting GitHub release
|
||||
notes.
|
||||
|
||||
## Docs Metadata
|
||||
|
||||
Every Markdown file under `docs/` should start with a short metadata block
|
||||
immediately after the H1 title:
|
||||
|
||||
```md
|
||||
# Document Title
|
||||
|
||||
- Status: Canonical | Historical | Working note
|
||||
- Last reviewed: YYYY-MM-DD
|
||||
- Update when: <short trigger for future updates>
|
||||
```
|
||||
|
||||
Use exactly one `Status` value:
|
||||
|
||||
- `Canonical`: current guidance that contributors should follow when changing
|
||||
code, tests, documentation, or release notes.
|
||||
- `Historical`: audit reports, completed investigation notes, or records kept
|
||||
mainly for context. Update only to correct stale cross-references, clarify
|
||||
status, or add a clear supersession note.
|
||||
- `Working note`: design notes, unresolved proposals, or decision records that
|
||||
are still useful but not the source of truth.
|
||||
|
||||
`Last reviewed` should be the date when the document was last checked against
|
||||
the repository's current behavior or current documentation policy. Use
|
||||
`YYYY-MM-DD`.
|
||||
|
||||
`Update when` should name the concrete trigger that makes the document relevant
|
||||
again, for example:
|
||||
|
||||
- `Update when module boundaries or dependency direction change.`
|
||||
- `Update when a listed issue changes status or a new issue is added.`
|
||||
- `Update when advanced Simple config behavior changes.`
|
||||
|
||||
## Docs File Naming
|
||||
|
||||
Do not rename existing documentation files only to match naming conventions.
|
||||
Preserving Git history and stable links is more important than cosmetic naming.
|
||||
|
||||
For new files under `docs/`, prefer these suffixes:
|
||||
|
||||
- `*-guidelines.md`: canonical process or implementation guidance
|
||||
- `*-rules.md`: canonical decision rules or constraints
|
||||
- `*-flow.md`: canonical runtime/data flow explanations
|
||||
- `*-notes.md`: working notes or design notes
|
||||
- `*-audit.md`: historical audit records
|
||||
- `*-history.md`: historical milestone or completed-project records
|
||||
|
||||
Existing files may keep older names when renaming would make history or links
|
||||
harder to follow.
|
||||
|
||||
## Document Roles
|
||||
|
||||
### `README.md` / `README.ja.md`
|
||||
|
||||
Entry points for first-time users.
|
||||
|
||||
Use these files for:
|
||||
|
||||
- Project overview
|
||||
- Provided nodes
|
||||
- Installation notes
|
||||
- Minimal model placement and first-use guidance
|
||||
- Short links to detailed reference documents
|
||||
- Important constraints users must know before trying the project
|
||||
|
||||
Avoid placing long compatibility notes, detailed runtime behavior, or full
|
||||
parameter explanations in the READMEs. Prefer short summaries with links to the
|
||||
appropriate reference document.
|
||||
|
||||
Short duplication is acceptable when it helps first-time users quickly decide
|
||||
whether the project is relevant or current. For example, the README may include:
|
||||
|
||||
- A compact tested-model summary, while detailed backend results and caveats
|
||||
remain canonical in `COMPATIBILITY.md`.
|
||||
- A short current-version summary, while full release history remains canonical
|
||||
in `CHANGELOG.md`.
|
||||
|
||||
### `PARAMETERS.md`
|
||||
|
||||
Reference for settings and runtime behavior.
|
||||
|
||||
Use this file for:
|
||||
|
||||
- UI parameters
|
||||
- Simple-node default behavior
|
||||
- `config_path` behavior
|
||||
- Cache, history, session, retry, fallback, and logging behavior
|
||||
- Practical guidance for how settings affect execution
|
||||
- Model-agnostic behavior such as chat template fallback
|
||||
|
||||
If the question is "what does this setting do?" or "what happens at runtime?",
|
||||
the answer usually belongs here.
|
||||
|
||||
### `ADVANCED_PARAMETERS.md`
|
||||
|
||||
Reference for advanced Simple-node JSON settings.
|
||||
|
||||
Use this file for:
|
||||
|
||||
- Settings available only through Simple-node JSON config
|
||||
- Advanced generation options
|
||||
- Experimental or future-facing JSON keys
|
||||
- Carefully-use settings such as fixed seeds
|
||||
- Notes about reproducibility and backend-sensitive advanced behavior
|
||||
|
||||
If a setting is intentionally not exposed in the UI and is configured through
|
||||
JSON, document it here.
|
||||
|
||||
### `COMPATIBILITY.md`
|
||||
|
||||
Reference for model and backend compatibility.
|
||||
|
||||
Use this file for:
|
||||
|
||||
- Tested model families
|
||||
- Vision model behavior
|
||||
- mmproj and chat handler compatibility
|
||||
- Backend-dependent behavior
|
||||
- `llama-cpp-python` version or fork differences
|
||||
- Empirical compatibility results and caveats
|
||||
|
||||
Avoid general usage notes, cache operation details, or migration notes unless
|
||||
they directly affect model/backend compatibility.
|
||||
|
||||
### `CHANGELOG.md`
|
||||
|
||||
Release history.
|
||||
|
||||
Use this file for:
|
||||
|
||||
- Every user-visible release change
|
||||
- New features
|
||||
- Bug fixes
|
||||
- Breaking or migration-relevant changes
|
||||
- Compatibility changes
|
||||
- Documentation updates
|
||||
|
||||
The changelog should summarize what changed and point to the relevant detailed
|
||||
document when needed.
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
Use commit messages as short, precise one-sentence summaries. Unless the user
|
||||
specifies a narrower scope, base the message on every change since the previous
|
||||
commit. Provide only the one-line version; do not add a body or detailed version
|
||||
unless the user explicitly requests one.
|
||||
|
||||
### Format
|
||||
|
||||
Prefer this format:
|
||||
|
||||
```text
|
||||
area: Imperative summary
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
docs: Clarify Simple-node config behavior
|
||||
compat: Add MiniCPM-V 4.6 aliases
|
||||
runtime: Fix cache invalidation after reset
|
||||
release: Prepare v1.2.4
|
||||
```
|
||||
|
||||
The `area:` prefix is recommended when it adds useful context. For very small
|
||||
or obvious changes, a plain imperative summary is acceptable.
|
||||
|
||||
### Style
|
||||
|
||||
- Use imperative mood: `Add`, `Fix`, `Update`, `Clarify`, `Refine`,
|
||||
`Prepare`.
|
||||
- Keep the message concise, ideally around 72 characters or less.
|
||||
- Do not end the subject line with a period.
|
||||
- Mention the user-visible behavior when possible.
|
||||
|
||||
### Suggested Areas
|
||||
|
||||
- `docs`: documentation-only changes
|
||||
- `compat`: model, backend, Vision, or llama-cpp-python compatibility
|
||||
- `runtime`: execution, cache, history, session, or fallback behavior
|
||||
- `ui`: node inputs, labels, tooltips, or exposed parameters
|
||||
- `tests`: tests and test infrastructure
|
||||
- `release`: version, changelog, and release preparation
|
||||
|
||||
## GitHub Release Guidelines
|
||||
|
||||
GitHub release notes should be easier to read than the changelog. They should
|
||||
highlight what users need to know first, then group details by category.
|
||||
|
||||
Unless the user explicitly asks for a file, draft GitHub release notes should be
|
||||
provided in the final response rather than written to a separate Markdown file.
|
||||
|
||||
### Release Title
|
||||
|
||||
Use this format:
|
||||
|
||||
```text
|
||||
vX.Y.Z – Short Theme
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
v1.2.4 – Documentation Cleanup and Backend Compatibility Notes
|
||||
v1.3.0 – New Simple Config Controls and Vision Diagnostics
|
||||
```
|
||||
|
||||
Use an en dash (`–`) between the version and theme.
|
||||
|
||||
### Release Notes Template
|
||||
|
||||
Use only the sections that apply.
|
||||
|
||||
```md
|
||||
This release focuses on ...
|
||||
|
||||
### Highlights
|
||||
|
||||
- ...
|
||||
|
||||
### Added
|
||||
|
||||
- ...
|
||||
|
||||
### Changed
|
||||
|
||||
- ...
|
||||
|
||||
### Fixed
|
||||
|
||||
- ...
|
||||
|
||||
### Compatibility
|
||||
|
||||
- ...
|
||||
|
||||
### Documentation
|
||||
|
||||
- ...
|
||||
|
||||
### Tests
|
||||
|
||||
- ...
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No action required.
|
||||
```
|
||||
|
||||
### Section Meanings
|
||||
|
||||
- `Highlights`: the most important user-facing summary
|
||||
- `Added`: new features, nodes, parameters, or config keys
|
||||
- `Changed`: behavior changes that are not bug fixes
|
||||
- `Fixed`: bug fixes
|
||||
- `Compatibility`: model, Vision, backend, or llama-cpp-python changes
|
||||
- `Documentation`: README, parameter docs, compatibility docs, or guidance
|
||||
- `Tests`: new or changed regression coverage
|
||||
- `Upgrade Notes`: required user actions or migration notes
|
||||
|
||||
Omit empty sections. If no action is required, say so clearly under
|
||||
`Upgrade Notes` only when users might otherwise wonder.
|
||||
|
||||
## Changelog Guidelines
|
||||
|
||||
`CHANGELOG.md` should be more complete and factual than GitHub release notes.
|
||||
|
||||
- Record every user-visible change.
|
||||
- Group entries by release version.
|
||||
- Use categories similar to release notes when helpful.
|
||||
- Link or point to detailed docs rather than duplicating long explanations.
|
||||
- Include documentation-only changes when they affect user guidance.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- If first-time users need it before trying the project, put a short note in
|
||||
`README.md` and `README.ja.md`.
|
||||
- If it is a setting or runtime behavior, put it in `PARAMETERS.md`.
|
||||
- If it is a Simple-node JSON-only setting, put it in `ADVANCED_PARAMETERS.md`.
|
||||
- If it depends on model family, Vision support, mmproj, chat handlers, or
|
||||
`llama-cpp-python` builds, put it in `COMPATIBILITY.md`.
|
||||
- If it describes when a change happened, put it in `CHANGELOG.md`.
|
||||
- If a topic spans multiple documents, keep the README short and link to the
|
||||
detailed reference document.
|
||||
- If a README summary intentionally duplicates reference content, keep it short,
|
||||
user-facing, and linked to the canonical document.
|
||||
|
||||
## Common Examples
|
||||
|
||||
### Adding a New Node
|
||||
|
||||
- `README.md` / `README.ja.md`: add a short entry under the provided nodes.
|
||||
- `PARAMETERS.md`: document any settings or runtime behavior.
|
||||
- `CHANGELOG.md`: record the new node.
|
||||
|
||||
### Adding a New UI Parameter
|
||||
|
||||
- `PARAMETERS.md`: document the parameter, valid values, defaults, and caveats.
|
||||
- `README.md` / `README.ja.md`: add only if it is important for first-time use.
|
||||
- `CHANGELOG.md`: record the addition.
|
||||
|
||||
### Adding a New Simple JSON Advanced Key
|
||||
|
||||
- `ADVANCED_PARAMETERS.md`: document the key and usage guidance.
|
||||
- `PARAMETERS.md`: link or mention it only if it relates to a general setting.
|
||||
- `CHANGELOG.md`: record the addition.
|
||||
|
||||
### Adding or Testing a New Model Family
|
||||
|
||||
- `COMPATIBILITY.md`: record tested behavior, backend requirements, and caveats.
|
||||
- `README.md`: update model family aliases only if users need them for model or
|
||||
mmproj placement.
|
||||
- `CHANGELOG.md`: record the compatibility update.
|
||||
|
||||
### Changing Cache, History, or Session Behavior
|
||||
|
||||
- `PARAMETERS.md`: document the new behavior and operational implications.
|
||||
- `README.md` / `README.ja.md`: add a short upgrade note only if existing users
|
||||
must take action.
|
||||
- `CHANGELOG.md`: record the change.
|
||||
|
||||
### Changing Installation Requirements
|
||||
|
||||
- `README.md` / `README.ja.md`: update the installation path.
|
||||
- `COMPATIBILITY.md`: update only if backend or model compatibility changes.
|
||||
- `CHANGELOG.md`: record the dependency or installation change.
|
||||
|
||||
## Style Notes
|
||||
|
||||
- Keep README sections short and user-facing.
|
||||
- Keep reference documents specific and factual.
|
||||
- Prefer stable descriptions over implementation details unless the behavior is
|
||||
user-visible.
|
||||
- Do not duplicate long explanations across documents. Link to the canonical
|
||||
reference instead.
|
||||
- When updating English README content that affects first-time use, update
|
||||
`README.ja.md` with the same intent.
|
||||
- Keep commit messages and release notes consistent in vocabulary:
|
||||
- Use `Vision`, `backend`, `Simple node`, `Simple-node JSON config`, and
|
||||
`llama-cpp-python` consistently.
|
||||
- Prefer `compatibility` for model/backend support and `runtime behavior` for
|
||||
execution semantics.
|
||||
- Prefer `release` over `version bump` when describing release preparation.
|
||||
|
||||
## AI Agent Checklist
|
||||
|
||||
When a user asks an AI assistant to update documentation according to this
|
||||
guide, the assistant should complete the following checklist.
|
||||
|
||||
### 1. Review the Change Scope
|
||||
|
||||
- Identify the actual user-visible change, feature, fix, compatibility update,
|
||||
or documentation cleanup.
|
||||
- Determine which documents are canonical for the change using the document
|
||||
roles and placement rules above.
|
||||
- Check the affected `docs/` metadata block. Update `Last reviewed` when the
|
||||
document was checked, and update `Status` or `Update when` if the document's
|
||||
role has changed.
|
||||
- Check whether English and Japanese README content should be kept in sync.
|
||||
- Avoid editing unrelated sections only for style preferences.
|
||||
|
||||
### 2. Update the Documentation
|
||||
|
||||
- Put short first-use guidance in `README.md` / `README.ja.md`.
|
||||
- Put settings and runtime behavior in `PARAMETERS.md`.
|
||||
- Put Simple-node JSON-only advanced settings in `ADVANCED_PARAMETERS.md`.
|
||||
- Put model/backend compatibility in `COMPATIBILITY.md`.
|
||||
- Put release-history facts in `CHANGELOG.md`.
|
||||
- Remove duplicate explanations when a single canonical reference is better.
|
||||
- Ensure cross-document links point to the canonical reference document.
|
||||
|
||||
### 3. Verify the Result
|
||||
|
||||
#### Mechanical Checks
|
||||
|
||||
- Check local Markdown links when files were moved, renamed, or newly linked.
|
||||
- Check that every `docs/*.md` file has the required metadata block:
|
||||
`Status`, `Last reviewed`, and `Update when`.
|
||||
- Check that prose headings still form a natural reading order. Ignore headings
|
||||
inside fenced code examples when checking the document outline.
|
||||
- When `docs/architecture.md` changes, compare dependency-direction claims with
|
||||
production imports. If new cross-layer exceptions appear, either document the
|
||||
exception clearly or consider whether a lower-level `common/` layer is needed.
|
||||
- When code or docs are moved, check whether file/line references in historical
|
||||
notes, audits, or issue records need clarification.
|
||||
|
||||
#### Content Checks
|
||||
|
||||
- Review the affected sections for stale wording, duplicated guidance, and
|
||||
inconsistent terminology.
|
||||
- Confirm canonical and historical documents are not mixed. Historical counts,
|
||||
line numbers, and audit totals should be clearly marked as historical when
|
||||
they do not describe the current repository state.
|
||||
- Confirm that README content remains concise and that detailed explanations
|
||||
live in the reference documents.
|
||||
- When first-use guidance changes, check whether `README.md` and
|
||||
`README.ja.md` need matching updates.
|
||||
- When user-visible behavior, compatibility, or release-facing documentation
|
||||
changes, check whether `CHANGELOG.md` or GitHub release notes need updates.
|
||||
- When issue status changes, check whether `docs/known-issues.md` needs a new
|
||||
entry, a `Status` update, or a resolved-date note.
|
||||
|
||||
#### Verification Notes
|
||||
|
||||
- In the final response, state which checks were performed.
|
||||
- If tests were skipped because the change was documentation-only, say so.
|
||||
- If a check was intentionally skipped or left for follow-up, name it explicitly.
|
||||
|
||||
### 4. Output a Commit Message
|
||||
|
||||
After updating the documentation, provide one recommended one-line commit
|
||||
message using the commit message guidelines above. Unless the user specifies a
|
||||
narrower scope, review and summarize every change since the previous commit.
|
||||
Do not provide a body or detailed version unless explicitly requested.
|
||||
|
||||
Prefer this format:
|
||||
|
||||
```text
|
||||
docs: Imperative summary of the documentation update
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
docs: Organize Simple-node settings and compatibility references
|
||||
```
|
||||
|
||||
If the change includes release preparation, use:
|
||||
|
||||
```text
|
||||
release: Prepare vX.Y.Z
|
||||
```
|
||||
|
||||
### 5. Output GitHub Release Notes
|
||||
|
||||
Provide draft GitHub release notes using the release guidelines above. Include
|
||||
only sections that apply.
|
||||
|
||||
Also provide a release title in the `vX.Y.Z – Short Theme` format. Do not write
|
||||
release notes to a separate Markdown file unless the user explicitly requests a
|
||||
file.
|
||||
|
||||
For documentation-only work, this compact form is usually enough:
|
||||
|
||||
```md
|
||||
This release focuses on documentation clarity and maintenance guidance.
|
||||
|
||||
### Documentation
|
||||
|
||||
- ...
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No action required.
|
||||
```
|
||||
|
||||
For feature or behavior changes, include the relevant `Added`, `Changed`,
|
||||
`Fixed`, `Compatibility`, `Documentation`, `Tests`, and `Upgrade Notes`
|
||||
sections.
|
||||
|
||||
### 6. Final Response Requirements
|
||||
|
||||
In the final response, include:
|
||||
|
||||
- A concise summary of the documentation updates.
|
||||
- The recommended commit message.
|
||||
- Draft GitHub release notes.
|
||||
- Any verification that was performed, or a note if no tests were run because
|
||||
the change was documentation-only.
|
||||
@@ -1,8 +1,20 @@
|
||||
# Error Handling Audit Report
|
||||
|
||||
- Status: Historical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Clarifying historical status, fixing stale cross-references, or adding supersession notes.
|
||||
|
||||
Historical note: This document is the error-handling audit record from
|
||||
2026-03-22. It is not the current logging or exception-handling policy. For the
|
||||
current logging rules, see [`logging-guidelines.md`](logging-guidelines.md).
|
||||
Line-number references in this document refer to the code layout at the time of
|
||||
the audit and may not match current source files.
|
||||
|
||||
**Date**: 2026-03-22
|
||||
**Scope**: `except Exception:` error handling usage
|
||||
**Total Count**: 57 occurrences
|
||||
**Count note**: This is the historical audit count from 2026-03-22, not the
|
||||
current repository count.
|
||||
**Status**: P0/P1 improvements completed (2026-03-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Documentation Index
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Files are added to or removed from `docs/`, or document roles change.
|
||||
|
||||
This directory contains architecture notes, implementation rules, and historical
|
||||
records for ComfyUI-LLM-Session. Use this index to find the current source of
|
||||
truth before changing code or documentation.
|
||||
|
||||
## Start Here
|
||||
|
||||
- [`architecture.md`](architecture.md) - Current module boundaries, top-level
|
||||
directory roles, and dependency direction.
|
||||
- [`refactoring-rules.md`](refactoring-rules.md) - Current behavior-preserving
|
||||
refactoring rules, split criteria, and safety expectations.
|
||||
- [`known-issues.md`](known-issues.md) - Current issue index. Check `Open`
|
||||
entries before making behavior changes.
|
||||
- [`change-documentation-guidelines.md`](change-documentation-guidelines.md) -
|
||||
Rules for updating docs, README content, changelog entries, and release notes.
|
||||
|
||||
## Canonical Reference Docs
|
||||
|
||||
- [`advanced-simple-config-notes.md`](advanced-simple-config-notes.md) -
|
||||
Advanced Simple config behavior and implementation rules.
|
||||
- [`agent-development-plan.md`](agent-development-plan.md) - Agent-assisted
|
||||
development workflow improvement plan and rollout order.
|
||||
- [`logging-guidelines.md`](logging-guidelines.md) - Current logging,
|
||||
exception-handling, and silent-handler policy.
|
||||
- [`manual-test-checklist.md`](manual-test-checklist.md) - Selective manual
|
||||
ComfyUI validation checklist for runtime/UI-sensitive changes.
|
||||
- [`model-specific-parameter-flow.md`](model-specific-parameter-flow.md) -
|
||||
Model-family parameter precedence and update checklist.
|
||||
- [`testing-rules.md`](testing-rules.md) - Repository-wide test placement,
|
||||
fixture, parametrization, and regression-test rules.
|
||||
|
||||
## Historical Records
|
||||
|
||||
Historical documents preserve past review context. They are not current policy
|
||||
unless a canonical document explicitly says so.
|
||||
|
||||
- [`acceptable-silent-errors.md`](acceptable-silent-errors.md) - Historical
|
||||
2026-03-22 silent-error allowlist.
|
||||
- [`error-handling-audit.md`](error-handling-audit.md) - Historical 2026-03-22
|
||||
audit of broad exception handling.
|
||||
- [`refactoring-history.md`](refactoring-history.md) - Completed refactoring
|
||||
milestones and related historical notes.
|
||||
|
||||
## Maintenance Rule of Thumb
|
||||
|
||||
When a change touches module boundaries, logging, model-specific parameters,
|
||||
history behavior, tests, known issues, or release-facing behavior, check this
|
||||
index first and update the affected canonical document in the same change.
|
||||
@@ -1,5 +1,27 @@
|
||||
# Known Issues
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: A listed issue changes status or a new issue is added.
|
||||
|
||||
This file keeps both open and resolved issue records in place so history and
|
||||
regression context remain easy to follow. To find active work, check the
|
||||
`Open Issues` index or search for `Status: Open`.
|
||||
|
||||
## Open Issues
|
||||
|
||||
- [KV_cache load fails after llama-cpp-python update](#kv_cache-load-fails-after-llama-cpp-python-update)
|
||||
- [Summary updated_at can be newer than history meta saved_at](#summary-updated_at-can-be-newer-than-history-meta-saved_at)
|
||||
- [Gemma4 text-only output can enter channel/thought token sequences](#gemma4-text-only-output-can-enter-channelthought-token-sequences)
|
||||
|
||||
## Status Legend
|
||||
|
||||
- `Open`: unresolved and still eligible for implementation or verification work.
|
||||
- `Resolved`: fixed or otherwise closed; kept here for history and regression context.
|
||||
- `Tracking`: observation or refactoring candidate, not a confirmed defect.
|
||||
|
||||
## Issue Records
|
||||
|
||||
## dynamic_max_tokens does not retry on some n_ctx overflow errors
|
||||
- Status: Resolved (2026-03-22)
|
||||
- First recorded: 2026-03-21
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# Logging Guidelines
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Logging utilities, exception-handling policy, or silent-handler criteria change.
|
||||
|
||||
This document describes the logging conventions and best practices for ComfyUI-LLM-Session.
|
||||
|
||||
This is the canonical policy for current logging, exception-handling, and
|
||||
silent-handler decisions. Historical audit records such as
|
||||
[`error-handling-audit.md`](error-handling-audit.md) and
|
||||
[`acceptable-silent-errors.md`](acceptable-silent-errors.md) preserve past
|
||||
review context, but should not override the rules in this document.
|
||||
|
||||
## Log Levels
|
||||
|
||||
The project uses three log levels:
|
||||
@@ -135,11 +145,26 @@ def cleanup_temp_file(path: str) -> None:
|
||||
pass # Acceptable: temp file cleanup is best-effort
|
||||
```
|
||||
|
||||
**Note:** Silent errors (Pattern 3) should only be used when:
|
||||
1. The operation is truly optional
|
||||
2. Failure has no impact on user experience
|
||||
3. Logging would add noise without value
|
||||
4. The error is documented in `error-handling-audit.md`
|
||||
### When Silence Is Acceptable
|
||||
|
||||
Silent errors (Pattern 3) should only be used when all of the following are
|
||||
true:
|
||||
|
||||
1. Fallback behavior exists.
|
||||
2. Failure does not risk data corruption or data loss.
|
||||
3. Core functionality continues to work.
|
||||
4. Logging would add noise because the error is expected in normal operation.
|
||||
5. The issue can still be diagnosed through another path when needed.
|
||||
|
||||
Silent handlers should be reviewed when:
|
||||
|
||||
1. Users report issues that may be related to these code paths.
|
||||
2. Debugging becomes difficult due to lack of visibility.
|
||||
3. Fallback behavior changes.
|
||||
4. The error becomes more frequent due to external changes.
|
||||
|
||||
When a silent handler remains intentional, keep the rationale documented in the
|
||||
nearest relevant policy, audit, or code comment.
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -227,4 +252,5 @@ Log levels follow this hierarchy (most verbose to least verbose):
|
||||
|
||||
- `core/logging_utils.py` - Logging utility implementations
|
||||
- `docs/logging-guidelines.md` - This document
|
||||
- `docs/error-handling-audit.md` - Audit of all error handling locations
|
||||
- `docs/error-handling-audit.md` - Historical 2026-03-22 audit of error handling locations
|
||||
- `docs/acceptable-silent-errors.md` - Historical 2026-03-22 silent-error allowlist
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# Manual ComfyUI Test Checklist
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-11
|
||||
- Update when: Manual ComfyUI validation scope, triage, or checklist steps change.
|
||||
- Use when: A change may affect ComfyUI UI wiring, real graph execution, model
|
||||
loading, backend behavior, or user-visible runtime behavior.
|
||||
- Do not use when: The change is documentation-only, test-only, or fully covered
|
||||
by pytest with no ComfyUI runtime impact.
|
||||
|
||||
This checklist is not required for every change. Use the triage and routing
|
||||
matrix to select only the sections relevant to the change.
|
||||
|
||||
Agents must not mark manual checks as passed unless a human actually performed
|
||||
them. When manual validation is needed, agents should tell the human which
|
||||
sections of this file to run.
|
||||
|
||||
## How To Use
|
||||
|
||||
1. Complete the triage section.
|
||||
2. Use the routing matrix to choose the relevant manual sections.
|
||||
3. Run only the sections that apply to the change.
|
||||
4. Record the result in the change notes, PR, or verification plan.
|
||||
|
||||
## Triage
|
||||
|
||||
Check the categories affected by the change:
|
||||
|
||||
- [ ] Node registration, category, or display name
|
||||
- [ ] Node inputs, defaults, widgets, return types, or return names
|
||||
- [ ] Session Chat execution
|
||||
- [ ] Dialogue Cycle execution
|
||||
- [ ] Continue behavior
|
||||
- [ ] Summary behavior
|
||||
- [ ] KV cache behavior
|
||||
- [ ] GGUF discovery or model loading
|
||||
- [ ] Multimodal or image-capable model path
|
||||
- [ ] User-visible error handling or log output
|
||||
- [ ] Documentation-only change
|
||||
- [ ] Test-only change
|
||||
|
||||
Manual ComfyUI validation decision:
|
||||
|
||||
- [ ] Required
|
||||
- [ ] Not required
|
||||
|
||||
Reason:
|
||||
|
||||
-
|
||||
|
||||
## Routing Matrix
|
||||
|
||||
| Change type | Manual sections to run |
|
||||
| --- | --- |
|
||||
| Node registration, category, or display name | Minimal Common Checks, Node Interface Checks |
|
||||
| Node inputs, defaults, widgets, return types, or return names | Minimal Common Checks, Node Interface Checks |
|
||||
| Session Chat behavior | Minimal Common Checks, Session Chat Checks, Error Handling Checks if affected |
|
||||
| Dialogue Cycle behavior | Minimal Common Checks, Dialogue Cycle Checks, KV Cache Checks if affected |
|
||||
| Continue or summary behavior | Minimal Common Checks, Continue And Summary Checks |
|
||||
| KV cache behavior | Minimal Common Checks, KV Cache Checks |
|
||||
| GGUF discovery or model loading | Minimal Common Checks, Model And GGUF Discovery Checks |
|
||||
| Multimodal or image-capable model path | Minimal Common Checks, Multimodal Checks |
|
||||
| User-visible errors or runtime logging | Minimal Common Checks, Error Handling Checks |
|
||||
| Documentation-only change | Usually no manual ComfyUI check |
|
||||
| Test-only change | Usually no manual ComfyUI check |
|
||||
|
||||
## Result Format
|
||||
|
||||
Use this format when reporting manual validation:
|
||||
|
||||
```text
|
||||
Manual ComfyUI validation: performed / required but not performed / not required
|
||||
Sections run:
|
||||
- Minimal Common Checks
|
||||
- ...
|
||||
Environment:
|
||||
Model used:
|
||||
Result:
|
||||
Notes:
|
||||
```
|
||||
|
||||
## Minimal Common Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Any manual ComfyUI validation is required.
|
||||
|
||||
Skip when:
|
||||
|
||||
- Manual ComfyUI validation is not required.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] ComfyUI starts successfully.
|
||||
- [ ] ComfyUI-LLM-Session imports without startup errors.
|
||||
- [ ] The affected node appears in the expected ComfyUI category.
|
||||
- [ ] The affected node can be placed in a graph.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Node Interface Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Node registration, category, display name, inputs, defaults, widgets, return
|
||||
types, or return names changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect ComfyUI node UI or public node contracts.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] The node display name is correct.
|
||||
- [ ] The node category is correct.
|
||||
- [ ] Required inputs are visible.
|
||||
- [ ] Optional inputs are visible when expected.
|
||||
- [ ] Default values look correct.
|
||||
- [ ] Return sockets appear as expected.
|
||||
- [ ] Existing workflows can still load when compatibility should be preserved.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Session Chat Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Session Chat execution, prompt handling, message construction, generation
|
||||
orchestration, or session behavior changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect Session Chat runtime behavior.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] A minimal Session Chat workflow runs.
|
||||
- [ ] A simple text prompt produces a response.
|
||||
- [ ] The response is visible in the expected output.
|
||||
- [ ] Session/history behavior matches the change intent.
|
||||
- [ ] Re-running the node does not produce unexpected errors.
|
||||
|
||||
Optional checks:
|
||||
|
||||
- [ ] Simple node wrapper behavior still works.
|
||||
- [ ] Full node behavior still works.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Dialogue Cycle Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Dialogue Cycle request construction, multi-turn flow, role handling, or
|
||||
conversation loop behavior changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect Dialogue Cycle runtime behavior.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] A minimal Dialogue Cycle workflow runs.
|
||||
- [ ] The first turn produces the expected style of response.
|
||||
- [ ] A follow-up turn uses prior context when expected.
|
||||
- [ ] Outputs are routed to the expected sockets.
|
||||
- [ ] Re-running the graph does not produce unexpected errors.
|
||||
|
||||
Optional checks:
|
||||
|
||||
- [ ] Simple node wrapper behavior still works.
|
||||
- [ ] Full node behavior still works.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Continue And Summary Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Continue behavior, summary behavior, prompt rewriting, summary override, or
|
||||
related defaults changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect continue or summary paths.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] Continue behavior produces a coherent continuation when enabled.
|
||||
- [ ] Normal generation still works when continue behavior is disabled.
|
||||
- [ ] Summary behavior runs when enabled.
|
||||
- [ ] Summary-related outputs or history effects match the change intent.
|
||||
- [ ] Summary settings do not leak into unrelated generation paths.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## KV Cache Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- KV cache save/load behavior, session identity, history interaction, or
|
||||
cache-related UI defaults changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not touch KV cache state or cache-related controls.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] Cache can be saved when expected.
|
||||
- [ ] Cache can be loaded when expected.
|
||||
- [ ] Generation still works when cache is disabled.
|
||||
- [ ] Cache-related errors are understandable.
|
||||
- [ ] Cache behavior across repeated turns matches the change intent.
|
||||
|
||||
Optional checks:
|
||||
|
||||
- [ ] Cache behavior with summary enabled.
|
||||
- [ ] Cache behavior after changing session identifiers.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Model And GGUF Discovery Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- GGUF discovery, model path handling, model loading, chat handler selection, or
|
||||
model-family routing changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect model discovery or loading.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] Expected GGUF files appear in selectable model inputs.
|
||||
- [ ] Case-insensitive discovery behavior works when relevant.
|
||||
- [ ] A supported text model loads.
|
||||
- [ ] A minimal generation works after model load.
|
||||
- [ ] Model loading errors are understandable when a model is missing or invalid.
|
||||
|
||||
Optional checks:
|
||||
|
||||
- [ ] Model-family-specific parameter behavior matches the change intent.
|
||||
- [ ] Unload model behavior still works when relevant.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Multimodal Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- Image input handling, vision model routing, multimodal chat handler selection,
|
||||
or image-capable model behavior changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change only affects text-only generation paths.
|
||||
- No image-capable model is available locally. Record this as not performed, not
|
||||
passed.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] Image input can be connected to the affected node.
|
||||
- [ ] A supported image-capable model can be selected.
|
||||
- [ ] A minimal image prompt produces a response.
|
||||
- [ ] Text-only generation still works when no image is provided.
|
||||
- [ ] Missing multimodal dependency or handler errors are understandable.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Error Handling Checks
|
||||
|
||||
Required when:
|
||||
|
||||
- User-visible error messages, logging, exception handling, fallback behavior, or
|
||||
validation changed.
|
||||
|
||||
Skip when:
|
||||
|
||||
- The change does not affect runtime errors, logs, or validation paths.
|
||||
|
||||
Required checks:
|
||||
|
||||
- [ ] The expected error path can be triggered.
|
||||
- [ ] The UI or console message is understandable.
|
||||
- [ ] The log includes enough detail for troubleshooting.
|
||||
- [ ] Sensitive or excessively noisy details are not exposed.
|
||||
- [ ] Normal successful execution still works.
|
||||
|
||||
Result:
|
||||
|
||||
- [ ] Passed
|
||||
- [ ] Failed
|
||||
- [ ] Not applicable
|
||||
|
||||
Notes:
|
||||
|
||||
-
|
||||
|
||||
## Final Manual Validation Summary
|
||||
|
||||
- Manual ComfyUI validation: performed / required but not performed / not required
|
||||
- Sections selected:
|
||||
- Sections completed:
|
||||
- Environment:
|
||||
- Model used:
|
||||
- Result:
|
||||
- Remaining risk:
|
||||
@@ -1,12 +1,328 @@
|
||||
# Model-Specific Parameter Flow
|
||||
|
||||
This document records how model-family-specific parameters are wired through the
|
||||
node layer. It exists to prevent configuration maps from becoming declarations
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-10-08
|
||||
- Update when: Model-family maps or parameter precedence behavior changes.
|
||||
|
||||
This document defines the implementation rules for model-family-specific
|
||||
parameters. It exists to prevent configuration maps from becoming declarations
|
||||
that are not actually consumed by a runtime path.
|
||||
|
||||
## Qwen3.5 Reference Flow
|
||||
## Purpose
|
||||
|
||||
Qwen3.5 currently uses all three model-specific maps:
|
||||
Model-specific parameters may affect different execution paths depending on how
|
||||
the model is loaded and how messages are built. Adding a key to one map is not
|
||||
enough by itself. The corresponding runtime path must read, validate, merge, and
|
||||
consume that key.
|
||||
|
||||
Use this document when adding or changing:
|
||||
|
||||
- model-family detection or aliases
|
||||
- chat handler kwargs
|
||||
- text-only chat builder config
|
||||
- summary text builder forced overrides
|
||||
- Simple config per-model overrides
|
||||
- backend compatibility fallbacks for unsupported handler kwargs
|
||||
|
||||
## Model Family Detection
|
||||
|
||||
Model-family-specific behavior starts with `_detect_model_family(model_path)`.
|
||||
The detected family key is used to select entries from the model-specific maps.
|
||||
|
||||
When adding a new family or alias:
|
||||
|
||||
1. Add detection for the canonical family key.
|
||||
2. Add aliases or filename patterns for known model naming variants.
|
||||
3. Add tests that prove each expected filename resolves to the canonical key.
|
||||
4. Keep map keys aligned with the canonical key returned by detection.
|
||||
|
||||
Detection should be conservative. If a filename cannot be confidently mapped to a
|
||||
family, prefer no model-specific behavior over applying the wrong behavior.
|
||||
|
||||
## Runtime Paths
|
||||
|
||||
Model-specific parameters currently flow through three runtime paths.
|
||||
|
||||
### 1. Vision Chat Handler Path
|
||||
|
||||
This path is used when a model family is detected and an mmproj is available,
|
||||
explicitly provided, or auto-detected so the model is loaded with vision support.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `CHAT_HANDLER_KWARGS_MAP` defines built-in kwargs for each chat format.
|
||||
2. `_load_available_chat_handlers()` builds handler factories from that map at
|
||||
module import time, then applies compatibility aliases only for handler
|
||||
classes that are missing from the installed backend.
|
||||
3. `GGUFModelManager.load_model()` detects the model family from the model file
|
||||
name.
|
||||
4. `_get_chat_handler_kwargs()` starts with
|
||||
`CHAT_HANDLER_KWARGS_MAP[model_family]`.
|
||||
5. Per-model Simple config overrides are merged without replacing explicit
|
||||
override values with Full-node defaults.
|
||||
6. `_instantiate_chat_handler()` passes the final kwargs to the backend handler.
|
||||
7. Unsupported optional kwargs may be dropped only by targeted compatibility
|
||||
fallback logic.
|
||||
|
||||
Use this path for parameters consumed by llama-cpp-python chat handler classes,
|
||||
such as `enable_thinking` or `image_min_tokens`.
|
||||
|
||||
### 2. Text-Only Builder Path
|
||||
|
||||
This path is used when the node runs without a vision chat handler and builds a
|
||||
completion-style prompt in the node layer.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `_build_text_chat()` detects the model family from the model path.
|
||||
2. If the family exists in `TEXT_CHAT_BUILDER_CONFIG_MAP`, the map entry is
|
||||
copied into a local config dict.
|
||||
3. Request-level `text_chat_builder_config` values are merged on top.
|
||||
4. The builder consumes supported keys while constructing the prompt.
|
||||
|
||||
Use this path for parameters that affect text-only prompt construction, such as
|
||||
whether thinking markup should be enabled or suppressed.
|
||||
|
||||
### 3. Summary Text Builder Forced Override Path
|
||||
|
||||
Summary generation may reuse text-chat prompt building, but it must be stable and
|
||||
compact. Some model families need forced summary overrides even if the normal
|
||||
request config differs.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Summary helpers receive `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`.
|
||||
2. The detected model family selects forced summary config.
|
||||
3. Forced summary config wins over request-level text builder config for summary
|
||||
generation.
|
||||
4. Normal generation config is not implicitly copied into summary generation.
|
||||
|
||||
Use this path for parameters that must be fixed during summary generation, such
|
||||
as `enable_thinking: false`.
|
||||
|
||||
## Model-Specific Maps
|
||||
|
||||
### `CHAT_HANDLER_KWARGS_MAP`
|
||||
|
||||
Defines built-in kwargs for backend chat handler classes.
|
||||
|
||||
Current examples:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP = {
|
||||
"gemma4": {"enable_thinking": False, "image_max_tokens": 512},
|
||||
"minicpm-v-4.6": {"enable_thinking": False},
|
||||
"qwen2.5-vl": {"image_min_tokens": 1024},
|
||||
"qwen3-vl": {"image_min_tokens": 1024},
|
||||
"qwen3.5": {"enable_thinking": False, "image_min_tokens": 1024},
|
||||
"qwen3.8": {"enable_thinking": False, "image_min_tokens": 1024},
|
||||
}
|
||||
```
|
||||
|
||||
Only add keys that the target chat handler path can consume or safely ignore via
|
||||
targeted fallback.
|
||||
|
||||
### `TEXT_CHAT_BUILDER_CONFIG_MAP`
|
||||
|
||||
Defines built-in config for text-only prompt construction.
|
||||
|
||||
Current examples:
|
||||
|
||||
```python
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP = {
|
||||
"gemma4": {"enable_thinking": False},
|
||||
"minicpm-v-4.6": {"enable_thinking": False},
|
||||
"qwen3.5": {"enable_thinking": False},
|
||||
"qwen3.8": {"enable_thinking": False},
|
||||
}
|
||||
```
|
||||
|
||||
Only add keys that `_build_text_chat()` or its helpers explicitly consume.
|
||||
|
||||
### `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`
|
||||
|
||||
Defines forced model-specific config for summary text prompt construction.
|
||||
|
||||
Current examples:
|
||||
|
||||
```python
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP = {
|
||||
"gemma4": {"enable_thinking": False},
|
||||
"minicpm-v-4.6": {"enable_thinking": False},
|
||||
"qwen3.5": {"enable_thinking": False},
|
||||
"qwen3.8": {"enable_thinking": False},
|
||||
}
|
||||
```
|
||||
|
||||
Forced summary overrides are intentionally stronger than request-level text
|
||||
builder config during summary generation.
|
||||
|
||||
## Parameter Categories
|
||||
|
||||
### `enable_thinking`
|
||||
|
||||
`enable_thinking` may be consumed by all three runtime paths:
|
||||
|
||||
- chat handler kwargs for vision-capable handlers
|
||||
- text-only builder config for completion-style prompts
|
||||
- summary forced overrides for compact summary generation
|
||||
|
||||
When adding `enable_thinking` support for a new family, verify each path that can
|
||||
run for that family. A model with both vision and text-only paths may need all
|
||||
three map entries.
|
||||
|
||||
### `image_min_tokens`
|
||||
|
||||
`image_min_tokens` is currently a chat-handler kwarg for Qwen VL-style vision
|
||||
handlers. It belongs in `CHAT_HANDLER_KWARGS_MAP` only unless a future text-only
|
||||
or summary path explicitly consumes it.
|
||||
|
||||
Unsupported `image_min_tokens` should be handled by targeted backend fallback
|
||||
only when the error clearly identifies that kwarg as unsupported.
|
||||
|
||||
### `image_max_tokens`
|
||||
|
||||
`image_max_tokens` is a chat-handler kwarg fixed at 512 for Gemma 4 in
|
||||
`CHAT_HANDLER_KWARGS_MAP`. It is not configurable from Simple config; users
|
||||
control image detail with `advanced_generation_kwargs.image_max_pixels`. It is
|
||||
not a text-builder or summary-builder setting.
|
||||
|
||||
The value is 512 because Gemma 4 image tokens use non-causal attention, and
|
||||
llama.cpp aborts the process when one image exceeds `n_ubatch`, which defaults
|
||||
to 512 in llama-cpp-python. Simple config `advanced_backend_kwargs.n_batch` and
|
||||
`n_ubatch` can raise the batch sizes but are clamped to at least 512, so the
|
||||
fixed value stays safe.
|
||||
|
||||
Unsupported `image_max_tokens` is handled by the same targeted optional-kwarg
|
||||
fallback as `image_min_tokens`.
|
||||
|
||||
### Future Parameters
|
||||
|
||||
For future parameters, first identify the runtime path:
|
||||
|
||||
- Backend handler constructor option: `CHAT_HANDLER_KWARGS_MAP`
|
||||
- Text-only prompt construction option: `TEXT_CHAT_BUILDER_CONFIG_MAP`
|
||||
- Summary-only forced behavior: `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP`
|
||||
|
||||
Do not add a parameter to every map by default. Add it only to paths that consume
|
||||
or intentionally force that behavior.
|
||||
|
||||
## Override Precedence
|
||||
|
||||
Model-specific values may come from multiple layers:
|
||||
|
||||
1. Explicit per-model Simple config overrides
|
||||
2. Explicit Full-node UI input values
|
||||
3. Simple built-in defaults / Full UI defaults
|
||||
4. Built-in model-family fallback maps
|
||||
|
||||
Simple nodes may delegate to Full-node methods for compatibility, but Full-node
|
||||
default arguments must not overwrite explicit Simple config overrides.
|
||||
|
||||
Merge helpers that apply UI defaults must preserve existing per-model override
|
||||
values and only fill missing keys. Prefer helper names that make this rule
|
||||
visible, such as names containing `preserving_explicit_overrides`.
|
||||
|
||||
## Backend Compatibility And Fallback
|
||||
|
||||
Backend chat handler signatures differ across llama-cpp-python versions and
|
||||
handler classes.
|
||||
|
||||
Allowed compatibility behavior:
|
||||
|
||||
- Prefer `mmproj_path` for new handlers.
|
||||
- Fall back to `clip_model_path` only when the TypeError clearly indicates
|
||||
`mmproj_path` is unsupported.
|
||||
- Drop unsupported optional kwargs such as `enable_thinking`,
|
||||
`image_min_tokens`, or `image_max_tokens` only when the TypeError clearly
|
||||
identifies that kwarg.
|
||||
- Preserve unrelated TypeErrors and surface them as real failures.
|
||||
|
||||
Do not catch all handler construction failures and retry with silently reduced
|
||||
config. Broad fallback can hide real backend, model, or path errors.
|
||||
|
||||
## Simple Config Interaction
|
||||
|
||||
Simple config may provide per-model overrides for supported model-specific
|
||||
parameters. These overrides should be applied consistently to the runtime paths
|
||||
that consume the parameter.
|
||||
|
||||
Rules:
|
||||
|
||||
- Treat Simple config values as explicit per-model overrides.
|
||||
- Do not let Full-node default arguments replace explicit Simple config values.
|
||||
- Coerce known value types before applying them to runtime maps.
|
||||
- Ignore unsupported per-model keys unless a supported runtime path consumes
|
||||
them.
|
||||
- Add regression coverage before changing merge precedence.
|
||||
|
||||
## Required Tests
|
||||
|
||||
When adding or changing a model-specific parameter, add focused tests for the
|
||||
paths that can consume it.
|
||||
|
||||
Recommended coverage:
|
||||
|
||||
- model-family detection and alias matching
|
||||
- chat handler kwargs selection
|
||||
- handler compatibility fallback for unsupported kwargs, when relevant
|
||||
- text-only builder consumption
|
||||
- summary forced override precedence
|
||||
- Simple config override precedence over Full-node defaults
|
||||
- negative tests proving unrelated TypeErrors are not swallowed
|
||||
|
||||
Existing tests to use as patterns:
|
||||
|
||||
- `tests/test_model_family_aliases.py`
|
||||
- `tests/test_model_specific_parameter_flow.py`
|
||||
- `tests/test_input_type_defaults.py`
|
||||
- `tests/services/test_turn_execution_service.py`
|
||||
|
||||
## Current Model Family Examples
|
||||
|
||||
### Gemma4
|
||||
|
||||
Gemma4 uses `enable_thinking` in all three maps:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["gemma4"] = {"enable_thinking": False}
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP["gemma4"] = {"enable_thinking": False}
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["gemma4"] = {"enable_thinking": False}
|
||||
```
|
||||
|
||||
This ensures vision chat handlers, text-only prompt building, and summary
|
||||
generation all suppress thinking behavior by default.
|
||||
|
||||
### MiniCPM-V-4.6
|
||||
|
||||
MiniCPM-V-4.6 uses `enable_thinking` in all three maps:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["minicpm-v-4.6"] = {"enable_thinking": False}
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP["minicpm-v-4.6"] = {"enable_thinking": False}
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["minicpm-v-4.6"] = {"enable_thinking": False}
|
||||
```
|
||||
|
||||
The chat-handler entry applies only to Vision mode. The text-builder entries
|
||||
apply to Text-only completion-style prompts and summary generation.
|
||||
|
||||
### Qwen VL Families
|
||||
|
||||
Qwen VL-style vision handlers use `image_min_tokens`:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["qwen2.5-vl"] = {"image_min_tokens": 1024}
|
||||
CHAT_HANDLER_KWARGS_MAP["qwen3-vl"] = {"image_min_tokens": 1024}
|
||||
```
|
||||
|
||||
`image_min_tokens` is not a text-builder or summary-builder setting.
|
||||
|
||||
### Qwen3.5-Compatible Families
|
||||
|
||||
Qwen3.5 and Qwen3.6 normalize to the canonical `qwen3.5` family. Qwen3.8
|
||||
normalizes to the separate canonical `qwen3.8` family so its Simple config does
|
||||
not inherit Qwen3.5 settings. Both canonical families use
|
||||
`Qwen35ChatHandler` and all three maps:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["qwen3.5"] = {
|
||||
@@ -21,199 +337,41 @@ TEXT_CHAT_BUILDER_CONFIG_MAP["qwen3.5"] = {
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["qwen3.5"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
```
|
||||
|
||||
These maps serve different runtime paths. Adding a key to a map is not enough by
|
||||
itself; the corresponding execution path must read and consume that key.
|
||||
CHAT_HANDLER_KWARGS_MAP["qwen3.8"] = {
|
||||
"enable_thinking": False,
|
||||
"image_min_tokens": 1024,
|
||||
}
|
||||
|
||||
Gemma4 follows the same design for `enable_thinking`:
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["gemma4"] = {
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP["qwen3.8"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP["gemma4"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["gemma4"] = {
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["qwen3.8"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
```
|
||||
|
||||
MiniCPM-V-4.6 also uses all three maps for `enable_thinking`. The chat-handler
|
||||
entry applies only to Vision mode, while the text-builder entries apply only to
|
||||
Text-only completion-style prompts:
|
||||
This combines the Qwen VL-style image token budget with thinking suppression for
|
||||
text-only and summary prompt building.
|
||||
|
||||
```python
|
||||
CHAT_HANDLER_KWARGS_MAP["minicpm-v-4.6"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
TEXT_CHAT_BUILDER_CONFIG_MAP["minicpm-v-4.6"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["minicpm-v-4.6"] = {
|
||||
"enable_thinking": False,
|
||||
}
|
||||
```
|
||||
|
||||
## Chat Handler Path
|
||||
|
||||
The chat handler path is used when a model family is detected and an mmproj is
|
||||
available, or auto-detected, so the model is loaded with vision support.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `CHAT_HANDLER_KWARGS_MAP` defines built-in kwargs for each chat format.
|
||||
2. `_load_available_chat_handlers()` builds handler factories from that map at
|
||||
module import time.
|
||||
3. `GGUFModelManager.load_model()` detects the model family from the model file
|
||||
name.
|
||||
4. `_get_chat_handler_kwargs()` starts with
|
||||
`CHAT_HANDLER_KWARGS_MAP[model_family]`.
|
||||
5. If `chat_handler_overrides` contains the same model family, those values are
|
||||
merged on top.
|
||||
6. The selected chat handler is instantiated with:
|
||||
|
||||
```python
|
||||
handler_cls(
|
||||
clip_model_path=mmproj_path,
|
||||
**active_chat_handler_kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
For Qwen3.5 vision/chat-handler mode, this is where `enable_thinking` and
|
||||
`image_min_tokens` are consumed.
|
||||
|
||||
## Text Chat Builder Path
|
||||
|
||||
The text chat builder path is used only when `_build_text_chat_request()` returns
|
||||
a request. It is intended for chat formats that need completion-style prompts
|
||||
instead of normal `create_chat_completion()` messages.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `TEXT_CHAT_BUILDER_CONFIG_MAP` declares model families that may use a text
|
||||
builder and their default builder config.
|
||||
2. `_build_text_chat_request()` rejects the text-builder path if `mmproj_path` is
|
||||
present, or if any message content includes non-text parts.
|
||||
3. `_detect_model_family()` maps aliases such as `qwen3.5`, `qwen35`,
|
||||
`qwen3.6`, and `qwen36` to `qwen3.5`.
|
||||
4. `_get_text_chat_builder_config()` starts with
|
||||
`TEXT_CHAT_BUILDER_CONFIG_MAP[model_family]`.
|
||||
5. If `text_chat_builder_overrides` contains the same model family, those values
|
||||
are merged on top.
|
||||
6. `_build_text_chat_request()` must explicitly dispatch the model family to a
|
||||
prompt builder.
|
||||
|
||||
For Qwen3.5, the dispatch is:
|
||||
|
||||
```python
|
||||
if model_family == "qwen3.5":
|
||||
prompt, stop = _build_qwen35_text_prompt(messages, config)
|
||||
```
|
||||
|
||||
For Gemma4, the dispatch follows the same rule:
|
||||
|
||||
```python
|
||||
if model_family == "gemma4":
|
||||
prompt, stop = _build_gemma4_text_prompt(messages, config)
|
||||
```
|
||||
|
||||
For MiniCPM-V-4.6, the dispatch follows the same rule:
|
||||
|
||||
```python
|
||||
if model_family == "minicpm-v-4.6":
|
||||
prompt, stop = _build_minicpm_v46_text_prompt(messages, config)
|
||||
```
|
||||
|
||||
`_build_qwen35_text_prompt()` consumes `config["enable_thinking"]`:
|
||||
|
||||
- `True`: appends an opening `<think>` marker.
|
||||
- `False`: appends an empty `<think></think>` block to discourage thinking
|
||||
output.
|
||||
|
||||
`_build_gemma4_text_prompt()` also consumes `config["enable_thinking"]`:
|
||||
|
||||
- `True`: prepends `<|think|>` to the effective system prompt before it is
|
||||
folded into the first user turn.
|
||||
- `False`: does not add any thinking marker or empty thought-channel prefix.
|
||||
|
||||
`_build_minicpm_v46_text_prompt()` consumes `config["enable_thinking"]`:
|
||||
|
||||
- `True`: appends an opening `<think>` marker after
|
||||
`<|im_start|>assistant`.
|
||||
- `False`: appends an empty `<think></think>` block after
|
||||
`<|im_start|>assistant` to discourage thinking output.
|
||||
|
||||
The resulting request is later executed by `_create_text_or_chat_completion()`
|
||||
using `llm.create_completion(...)`.
|
||||
|
||||
Important: a model family listed in `TEXT_CHAT_BUILDER_CONFIG_MAP` still needs an
|
||||
explicit dispatch branch in `_build_text_chat_request()`. Without that branch,
|
||||
the config is declared but not consumed.
|
||||
|
||||
## Summary Text Builder Forced Overrides
|
||||
|
||||
Summarization uses the same text-or-chat completion helper as normal chat, but
|
||||
it may force stricter builder config to avoid reasoning in summaries.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `_summarize_with_model()` and `maybe_compact_summary()` call
|
||||
`_create_text_or_chat_completion()`.
|
||||
2. They pass:
|
||||
|
||||
```python
|
||||
forced_text_chat_builder_overrides_map=SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP
|
||||
```
|
||||
|
||||
3. `_create_text_or_chat_completion()` calls
|
||||
`_merge_text_chat_builder_overrides()`.
|
||||
4. `_merge_text_chat_builder_overrides()` detects the model family from
|
||||
`model_path`.
|
||||
5. It starts with request-level `text_chat_builder_overrides`, then applies
|
||||
`SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP[model_family]` on top.
|
||||
6. `_build_text_chat_request()` must still dispatch the model family to a real
|
||||
builder before the forced config can affect runtime behavior.
|
||||
|
||||
For Qwen3.5 summaries, `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP["qwen3.5"]` forces
|
||||
`enable_thinking=False`, then `_build_qwen35_text_prompt()` consumes that value.
|
||||
MiniCPM-V-4.6 follows the same summary rule through
|
||||
`_build_minicpm_v46_text_prompt()`.
|
||||
|
||||
## Override Precedence
|
||||
|
||||
For normal user-facing generation, the intended precedence is documented in
|
||||
`docs/architecture.md`:
|
||||
|
||||
1. Explicit per-model config overrides
|
||||
2. Explicit Full-node UI input values
|
||||
3. Simple built-in defaults / Full UI defaults
|
||||
4. Model-family fallback maps
|
||||
|
||||
For summary generation, `SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP` is intentionally
|
||||
stronger than request-level builder overrides because summaries should be compact
|
||||
and should not expose reasoning.
|
||||
Qwen3.8 `reasoning_effort` is a node-owned prompt policy, not a chat-handler
|
||||
kwarg. The service builds a transient effective system prompt for normal and
|
||||
retry generation and supplies the same value to the KV-state signature. The
|
||||
original system prompt remains in history, and summary generation does not add
|
||||
the reasoning instruction because summary thinking is forced off.
|
||||
|
||||
## Checklist For Adding A Model-Specific Parameter
|
||||
|
||||
When adding a model-specific parameter to one of these maps, verify all relevant
|
||||
items:
|
||||
Before adding a key to any model-specific map:
|
||||
|
||||
1. The map declaration exists in the correct map.
|
||||
2. Simple config loading reads the key if users can override it.
|
||||
3. Full UI merge preserves explicit Simple config overrides.
|
||||
4. The runtime path consumes the key:
|
||||
- chat handler kwargs are passed to the handler constructor, or
|
||||
- text builder config is passed to a concrete prompt builder, or
|
||||
- summary forced config reaches a concrete text builder.
|
||||
5. `_build_text_chat_request()` has an explicit dispatch branch for any model
|
||||
listed in `TEXT_CHAT_BUILDER_CONFIG_MAP`.
|
||||
6. Tests cover both:
|
||||
- precedence, so explicit config is not overwritten by defaults
|
||||
- consumption, so the configured key changes the built request or constructor
|
||||
kwargs used by the relevant path
|
||||
1. Identify the canonical model family key returned by `_detect_model_family()`.
|
||||
2. Identify the runtime path that will consume the parameter.
|
||||
3. Add the key only to the map or maps used by that path.
|
||||
4. Add or update Simple config override handling if users may override it.
|
||||
5. Ensure merge precedence preserves explicit per-model overrides.
|
||||
6. Add backend compatibility fallback only for known unsupported-keyword cases.
|
||||
7. Add tests for each runtime path the parameter affects.
|
||||
8. Update this document if the parameter introduces a new category or rule.
|
||||
9. Update user-facing documentation only if the parameter becomes configurable by
|
||||
users.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Refactoring History
|
||||
|
||||
- Status: Historical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Clarifying historical status, fixing stale cross-references, or adding supersession notes.
|
||||
|
||||
Historical note: This document records completed refactoring milestones. For
|
||||
current refactoring rules, see [`refactoring-rules.md`](refactoring-rules.md).
|
||||
For current logging and exception-handling policy, see
|
||||
[`logging-guidelines.md`](logging-guidelines.md).
|
||||
|
||||
Counts, file lists, and test totals in this document refer to the repository
|
||||
state at the time of each milestone and may not match current source files.
|
||||
|
||||
## P0 Safety Net Baseline (2026-03-21)
|
||||
|
||||
1. Add pytest scaffolding under `tests/`.
|
||||
2. Lock behavior for `core/continue_rewrite.py`.
|
||||
3. Lock behavior for `core/kv_state.py`.
|
||||
4. Lock behavior for `core/generation_runner.py`.
|
||||
5. Lock orchestration behavior for `services/turn_execution_service.py`.
|
||||
|
||||
Note:
|
||||
|
||||
- These tests are intended to preserve current behavior during refactoring.
|
||||
- KNOWN_ISSUES remain tracked separately and are not changed by P0 test commits.
|
||||
|
||||
## Error Handling Improvement Project (2026-03-22)
|
||||
|
||||
### Completed Phases
|
||||
|
||||
- **Phase 2**: Created `core/logging_utils.py` with logging utilities
|
||||
- **Phase 3**: Improved 4 P0 error handling items with logging
|
||||
- **Phase 4**: Improved 8 P1 error handling items with logging
|
||||
- **Phase 5**: Documented 16 P3 acceptable silent errors
|
||||
- **Phase 6**: Added 8 tests for P0/P1 error handling improvements
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- Total tests: 73 (all passing)
|
||||
- P0/P1 error handling tests: 8 (in `TestErrorHandlingP0P1`)
|
||||
- No regressions from error handling improvements
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `core/logging_utils.py` (new) - Logging utilities
|
||||
- `services/turn_execution_service.py` - P0/P1 logging additions
|
||||
- `core/kv_state.py` - P1 logging addition
|
||||
- `llm_session_nodes.py` - P1 logging additions
|
||||
- `tests/services/test_turn_execution_service.py` - P0/P1 tests
|
||||
- `docs/error-handling-audit.md` - Status update
|
||||
- `docs/acceptable-silent-errors.md` (new) - P3 documentation
|
||||
@@ -1,5 +1,9 @@
|
||||
# Refactoring Rules (Project-Wide)
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Refactoring boundaries, split criteria, or safety-net expectations change.
|
||||
|
||||
This document defines the refactoring rules for this repository. It is intended to keep
|
||||
changes consistent and maintainable over time.
|
||||
|
||||
@@ -104,42 +108,6 @@ If these points are unclear, prefer defer + documentation over forced split.
|
||||
2. Do not mix functional changes with line-ending normalization in the same commit.
|
||||
3. If line-ending normalization is needed, perform it in a dedicated commit with a clear message.
|
||||
|
||||
|
||||
## P0 Safety Net Baseline (2026-03-21)
|
||||
1. Add pytest scaffolding under `tests/`.
|
||||
2. Lock behavior for `core/continue_rewrite.py`.
|
||||
3. Lock behavior for `core/kv_state.py`.
|
||||
4. Lock behavior for `core/generation_runner.py`.
|
||||
5. Lock orchestration behavior for `services/turn_execution_service.py`.
|
||||
|
||||
Note:
|
||||
- These tests are intended to preserve current behavior during refactoring.
|
||||
- KNOWN_ISSUES remain tracked separately and are not changed by P0 test commits.
|
||||
|
||||
## Error Handling Improvement Project (2026-03-22)
|
||||
|
||||
### Completed Phases
|
||||
|
||||
- **Phase 2**: Created `core/logging_utils.py` with logging utilities
|
||||
- **Phase 3**: Improved 4 P0 error handling items with logging
|
||||
- **Phase 4**: Improved 8 P1 error handling items with logging
|
||||
- **Phase 5**: Documented 16 P3 acceptable silent errors
|
||||
- **Phase 6**: Added 8 tests for P0/P1 error handling improvements
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- Total tests: 73 (all passing)
|
||||
- P0/P1 error handling tests: 8 (in `TestErrorHandlingP0P1`)
|
||||
- No regressions from error handling improvements
|
||||
|
||||
### Files Modified
|
||||
|
||||
- `core/logging_utils.py` (new) - Logging utilities
|
||||
- `services/turn_execution_service.py` - P0/P1 logging additions
|
||||
- `core/kv_state.py` - P1 logging addition
|
||||
- `llm_session_nodes.py` - P1 logging additions
|
||||
- `tests/services/test_turn_execution_service.py` - P0/P1 tests
|
||||
- `docs/error-handling-audit.md` - Status update
|
||||
- `docs/acceptable-silent-errors.md` (new) - P3 documentation
|
||||
|
||||
|
||||
## Historical Notes
|
||||
Past refactoring milestones are recorded in
|
||||
[`refactoring-history.md`](refactoring-history.md).
|
||||
|
||||
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
# Verification Plan Template
|
||||
|
||||
Use this template for non-trivial changes and for any change where verification
|
||||
scope is easy to miss. Keep completed plans concise, and replace placeholder
|
||||
text with the actual commands, results, and decisions for the change.
|
||||
|
||||
For testing policy, see `../testing-rules.md`. For documentation policy, see
|
||||
`../change-documentation-guidelines.md`.
|
||||
|
||||
## Change Summary
|
||||
|
||||
- Change:
|
||||
- Behavior affected:
|
||||
- Files or modules affected:
|
||||
- Risk level: low / medium / high
|
||||
|
||||
## Automated Tests
|
||||
|
||||
List the pytest checks that should be run for this change.
|
||||
|
||||
- [ ] Focused pytest command:
|
||||
```powershell
|
||||
python -m pytest
|
||||
```
|
||||
- [ ] Full pytest command, when practical:
|
||||
```powershell
|
||||
python -m pytest -q
|
||||
```
|
||||
- [ ] All relevant tests passed.
|
||||
|
||||
Skipped automated checks and reason:
|
||||
|
||||
- None / reason:
|
||||
|
||||
## Regression Tests
|
||||
|
||||
Use this section for bug fixes or behavior that should not regress.
|
||||
|
||||
- [ ] A regression test is required.
|
||||
- [ ] A regression test was added or updated.
|
||||
- [ ] No regression test is needed.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- Test file:
|
||||
- Scenario protected:
|
||||
- Why this would have failed before, if applicable:
|
||||
|
||||
If no regression test was added, explain why:
|
||||
|
||||
- Reason:
|
||||
|
||||
## Contract Tests
|
||||
|
||||
Use this section when public interfaces, node contracts, defaults, mappings, or
|
||||
service request/response shapes may change.
|
||||
|
||||
- [ ] Node `INPUT_TYPES` reviewed.
|
||||
- [ ] Node `RETURN_TYPES` / `RETURN_NAMES` reviewed.
|
||||
- [ ] Node mappings reviewed.
|
||||
- [ ] Default values reviewed.
|
||||
- [ ] Service dataclass or request/response shape reviewed.
|
||||
- [ ] Model-specific parameter routing reviewed.
|
||||
- [ ] Contract tests were added or updated where needed.
|
||||
- [ ] No contract surface changed.
|
||||
|
||||
Contract coverage:
|
||||
|
||||
- Test file:
|
||||
- Contract protected:
|
||||
|
||||
## Manual ComfyUI Tests
|
||||
|
||||
Use this section for behavior pytest cannot fully prove. Do not mark manual
|
||||
validation as complete unless it was actually performed in ComfyUI.
|
||||
|
||||
- [ ] Manual ComfyUI validation is required.
|
||||
- [ ] Manual ComfyUI validation is not required for this change.
|
||||
|
||||
Recommended checks, when relevant:
|
||||
|
||||
- [ ] Node appears in the ComfyUI UI.
|
||||
- [ ] Basic Session Chat generation works.
|
||||
- [ ] Dialogue Cycle flow works.
|
||||
- [ ] Continue behavior works.
|
||||
- [ ] Summary behavior works.
|
||||
- [ ] KV cache behavior works.
|
||||
- [ ] GGUF discovery works.
|
||||
- [ ] Image-capable model path works, when available.
|
||||
- [ ] User-visible errors are understandable.
|
||||
|
||||
Manual validation result:
|
||||
|
||||
- Relevant `docs/manual-test-checklist.md` sections:
|
||||
- Section:
|
||||
Reason:
|
||||
- Performed by:
|
||||
- Environment:
|
||||
- Result:
|
||||
|
||||
Manual validation not performed:
|
||||
|
||||
- Reason:
|
||||
- Remaining risk:
|
||||
|
||||
## Documentation Checks
|
||||
|
||||
Review documentation impact before completion. Update files only when the change
|
||||
requires it.
|
||||
|
||||
- [ ] `README.md` reviewed.
|
||||
- [ ] `README.ja.md` reviewed.
|
||||
- [ ] `CHANGELOG.md` reviewed.
|
||||
- [ ] `docs/architecture.md` reviewed.
|
||||
- [ ] `docs/testing-rules.md` reviewed.
|
||||
- [ ] `docs/refactoring-rules.md` reviewed.
|
||||
- [ ] `docs/logging-guidelines.md` reviewed.
|
||||
- [ ] `docs/change-documentation-guidelines.md` reviewed.
|
||||
- [ ] No documentation update is required.
|
||||
|
||||
Documentation changes:
|
||||
|
||||
- Updated:
|
||||
- Not updated because:
|
||||
|
||||
## Out of Scope
|
||||
|
||||
List checks, platforms, model types, or workflows intentionally not covered by
|
||||
this verification plan.
|
||||
|
||||
- Out of scope:
|
||||
- Reason:
|
||||
|
||||
## Result Summary
|
||||
|
||||
- Focused tests:
|
||||
- Full test suite:
|
||||
- Regression coverage:
|
||||
- Contract coverage:
|
||||
- Manual ComfyUI validation:
|
||||
- Documentation:
|
||||
- Remaining risks:
|
||||
@@ -0,0 +1,157 @@
|
||||
# Testing Rules
|
||||
|
||||
- Status: Canonical
|
||||
- Last reviewed: 2026-07-05
|
||||
- Update when: Test structure, coverage expectations, fixture policy, or
|
||||
regression-test rules change.
|
||||
|
||||
This document defines repository-wide testing rules for ComfyUI-LLM-Session.
|
||||
Use it together with domain-specific test requirements in documents such as
|
||||
`model-specific-parameter-flow.md` and `advanced-simple-config-notes.md`.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. Behavior preservation is the first testing priority.
|
||||
2. Tests should lock user-visible behavior and compatibility-critical surfaces.
|
||||
3. Service boundaries should have direct tests, not only indirect node-level coverage.
|
||||
4. Refactors should be supported by existing tests or small additive tests in the same step.
|
||||
|
||||
## When Tests Are Required
|
||||
|
||||
Add or update tests when a change affects any of the following:
|
||||
|
||||
1. `services/*.py` classes, request objects, dependency wiring, or orchestration behavior.
|
||||
2. Cache, history, session, KV state, model unload, transcript, or filesystem side effects.
|
||||
3. Model-family detection, model-specific parameter flow, chat handler kwargs, or backend fallback.
|
||||
4. UI input defaults, enum/choice labels, sentinel strings, or Simple-node defaults.
|
||||
5. Bug fixes, especially when the broken condition can regress silently.
|
||||
|
||||
For service-layer changes, prefer a direct test in `tests/services/` even when a
|
||||
higher-level node test already exercises the path.
|
||||
|
||||
## Test Placement
|
||||
|
||||
1. Put `core/` tests under `tests/core/`.
|
||||
2. Put `services/` tests under `tests/services/`.
|
||||
3. Put `infra/` tests under `tests/infra/`.
|
||||
4. Keep `llm_session_nodes.py` UI/default/import/node-wrapper tests in focused
|
||||
top-level `tests/test_*.py` files.
|
||||
5. For model-specific parameter behavior, also follow the Required Tests section
|
||||
in `model-specific-parameter-flow.md`.
|
||||
6. For advanced Simple config behavior, also follow the documentation and test
|
||||
requirements in `advanced-simple-config-notes.md`.
|
||||
|
||||
## Side Effects
|
||||
|
||||
Side-effecting behavior should cover both success and failure paths when practical.
|
||||
|
||||
Examples include:
|
||||
|
||||
1. History save, backup, restore, quarantine, and schema migration.
|
||||
2. KV state save, restore, invalidation, and cache mismatch handling.
|
||||
3. Runtime cache configuration and invalidation.
|
||||
4. Model unload and runtime container cleanup.
|
||||
5. Transcript append and output file writes.
|
||||
|
||||
When a failure is intentionally tolerated, assert the returned status, recorded
|
||||
error, log output, or preserved state that makes the tolerance observable.
|
||||
|
||||
## Fixtures And Helpers
|
||||
|
||||
1. Do not copy the same test helper across many files.
|
||||
2. If the same helper, stub, or import setup is needed in three or more test
|
||||
files, move it to `tests/conftest.py` or a focused test helper module.
|
||||
3. Keep `folder_paths` stubs, `llm_session_nodes` reload setup, and runtime
|
||||
container stubs centralized when they are shared.
|
||||
4. Large dependency dictionaries should use a default builder plus explicit
|
||||
overrides instead of repeating full dictionaries in each test.
|
||||
5. Test helpers should keep defaults boring and make the behavior under test
|
||||
obvious at the call site.
|
||||
|
||||
## Parametrized Tests
|
||||
|
||||
Use `pytest.mark.parametrize` when the same assertion shape covers three or more
|
||||
cases.
|
||||
|
||||
Good candidates include:
|
||||
|
||||
1. Model family aliases and variant names.
|
||||
2. UI input defaults and option lists.
|
||||
3. Language-specific continue-rewrite behavior.
|
||||
4. Supported and unsupported media/input shapes.
|
||||
5. Error-message or backend-fallback cases with the same expected outcome.
|
||||
|
||||
Do not force a regression test into a parameter table when a named standalone
|
||||
test better explains the broken condition.
|
||||
|
||||
## Large Test Files
|
||||
|
||||
Avoid growing already-large test files by default.
|
||||
|
||||
1. When a test file is roughly 500 lines or larger, consider placing new tests in
|
||||
a focused sibling file.
|
||||
2. Split new turn-execution coverage by concern when possible: preflight,
|
||||
generation, persistence, KV state, node execution, or timing/logging.
|
||||
3. Existing large files do not need immediate mechanical splitting. Prefer
|
||||
incremental extraction when new tests or helper cleanup make the boundary clear.
|
||||
|
||||
## Testing Private Helpers
|
||||
|
||||
Prefer public functions, service methods, and request/dependency contracts as the
|
||||
main test surface.
|
||||
|
||||
Direct private-helper tests are acceptable when one of these applies:
|
||||
|
||||
1. The helper handles path safety, media conversion, model alias detection,
|
||||
config/default normalization, or backend compatibility fallback.
|
||||
2. The helper has many boundary conditions that would be noisy to reach through a
|
||||
higher-level API.
|
||||
3. The higher-level API would require heavy mocks or unrelated setup that hides
|
||||
the behavior being tested.
|
||||
|
||||
## Regression Test Names
|
||||
|
||||
Regression tests should name the condition that used to fail.
|
||||
|
||||
Prefer names like:
|
||||
|
||||
1. `test_load_history_reads_backup_when_primary_invalid`
|
||||
2. `test_restore_state_invalidates_cache_on_state_data_mismatch`
|
||||
3. `test_simple_config_override_preserves_model_specific_default`
|
||||
|
||||
Avoid vague names like `test_history_fix` or `test_kv_state_works`.
|
||||
|
||||
## Pytest Configuration
|
||||
|
||||
Keep pytest configuration strict enough to catch stale test metadata.
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
1. Use `--strict-config`.
|
||||
2. Use `--strict-markers`.
|
||||
3. Register any marker before using it.
|
||||
4. Introduce coverage measurement before introducing a hard coverage threshold.
|
||||
5. Raise coverage thresholds gradually after currently-thin service areas are
|
||||
covered directly.
|
||||
|
||||
## Checklist
|
||||
|
||||
Before finishing a code change, check:
|
||||
|
||||
1. Did service-layer behavior get a direct test?
|
||||
2. Did side-effect behavior cover success and failure paths where practical?
|
||||
3. Did repeated cases use parametrization where appropriate?
|
||||
4. Did new helpers avoid copy-paste across test files?
|
||||
5. Did regression tests describe the broken condition in the test name?
|
||||
6. Did domain-specific docs add any extra test requirements for this change?
|
||||
|
||||
## Related Documents
|
||||
|
||||
1. [`refactoring-rules.md`](refactoring-rules.md) - Refactoring safety-net and
|
||||
split criteria.
|
||||
2. [`model-specific-parameter-flow.md`](model-specific-parameter-flow.md) -
|
||||
Required tests for model-specific parameter changes.
|
||||
3. [`advanced-simple-config-notes.md`](advanced-simple-config-notes.md) -
|
||||
Advanced Simple config implementation and test requirements.
|
||||
4. [`change-documentation-guidelines.md`](change-documentation-guidelines.md) -
|
||||
Documentation, changelog, and release-note placement rules.
|
||||
@@ -1 +1 @@
|
||||
{"id":"f2bd0b37-347f-4ff9-bc36-e705fee41bd4","revision":0,"last_node_id":6,"last_link_id":4,"nodes":[{"id":2,"type":"PreviewAny","pos":[2350.3400367276968,304.2767138547244],"size":[490.5560057193238,166.28400910151828],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"source","name":"source","type":"*","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.70","Node name for S&R":"PreviewAny","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.5.1"}},"widgets_values":[null,null,false]},{"id":5,"type":"LLMSessionChatSimpleNode","pos":[1853.1783662489236,304.13561815013276],"size":[408.5346385922703,467.9192718859632],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"image","name":"image","shape":7,"type":"IMAGE","link":null},{"localized_name":"user_text","name":"user_text","type":"STRING","widget":{"name":"user_text"},"link":null},{"localized_name":"session_id","name":"session_id","type":"STRING","widget":{"name":"session_id"},"link":null},{"localized_name":"model","name":"model","type":"COMBO","widget":{"name":"model"},"link":null},{"localized_name":"mmproj","name":"mmproj","type":"COMBO","widget":{"name":"mmproj"},"link":null},{"localized_name":"history_dir","name":"history_dir","type":"STRING","widget":{"name":"history_dir"},"link":null},{"localized_name":"config_path","name":"config_path","shape":7,"type":"STRING","widget":{"name":"config_path"},"link":null}],"outputs":[{"localized_name":"assistant_text","name":"assistant_text","type":"STRING","links":[3,4]}],"properties":{"cnr_id":"comfyui-llm-session","ver":"9a46bf4af42a0734d288919bae55d614a5f21d23","Node name for S&R":"LLMSessionChatSimpleNode","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.5.1"}},"widgets_values":["Please prepare an explanation about the key points\nto consider when using a local LLM in real-world scenarios.\nDo not output the explanation yet.","default","","","",""]},{"id":6,"type":"UnloadLLMModelNode","pos":[2348.964691825616,553.9684295531191],"size":[270,58],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trigger","name":"trigger","shape":7,"type":"*","link":4},{"localized_name":"unload_now","name":"unload_now","type":"BOOLEAN","widget":{"name":"unload_now"},"link":null}],"outputs":[{"localized_name":"trigger","name":"trigger","type":"*","links":null}],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"cnr_id":"comfyui-llm-session","ver":"9a46bf4af42a0734d288919bae55d614a5f21d23","Node name for S&R":"UnloadLLMModelNode"},"widgets_values":[false]}],"links":[[3,5,0,2,0,"*"],[4,5,0,6,0,"STRING"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.8592411862551096,"offset":[-1632.828892161161,-142.36492093509267]},"workflowRendererVersion":"LG"},"version":0.4}
|
||||
{"id":"f2bd0b37-347f-4ff9-bc36-e705fee41bd4","revision":0,"last_node_id":6,"last_link_id":4,"nodes":[{"id":2,"type":"PreviewAny","pos":[2350.3400367276968,304.2767138547244],"size":[490.5560057193238,166.28400910151828],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"source","name":"source","type":"*","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.70","Node name for S&R":"PreviewAny","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.5.1"}},"widgets_values":[null,null,false]},{"id":5,"type":"LLMSessionChatSimpleNode","pos":[1853.1783662489236,304.13561815013276],"size":[408.5346385922703,467.9192718859632],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"media","name":"media","shape":7,"type":"*","link":null},{"localized_name":"user_text","name":"user_text","type":"STRING","widget":{"name":"user_text"},"link":null},{"localized_name":"session_id","name":"session_id","type":"STRING","widget":{"name":"session_id"},"link":null},{"localized_name":"model","name":"model","type":"COMBO","widget":{"name":"model"},"link":null},{"localized_name":"mmproj","name":"mmproj","type":"COMBO","widget":{"name":"mmproj"},"link":null},{"localized_name":"history_dir","name":"history_dir","type":"STRING","widget":{"name":"history_dir"},"link":null},{"localized_name":"config_path","name":"config_path","shape":7,"type":"STRING","widget":{"name":"config_path"},"link":null}],"outputs":[{"localized_name":"assistant_text","name":"assistant_text","type":"STRING","links":[3,4]}],"properties":{"cnr_id":"comfyui-llm-session","ver":"9a46bf4af42a0734d288919bae55d614a5f21d23","Node name for S&R":"LLMSessionChatSimpleNode","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.5.1"}},"widgets_values":["Please prepare an explanation about the key points\nto consider when using a local LLM in real-world scenarios.\nDo not output the explanation yet.","default","","","",""]},{"id":6,"type":"UnloadLLMModelNode","pos":[2348.964691825616,553.9684295531191],"size":[270,58],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"trigger","name":"trigger","shape":7,"type":"*","link":4},{"localized_name":"unload_now","name":"unload_now","type":"BOOLEAN","widget":{"name":"unload_now"},"link":null}],"outputs":[{"localized_name":"trigger","name":"trigger","type":"*","links":null}],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"cnr_id":"comfyui-llm-session","ver":"9a46bf4af42a0734d288919bae55d614a5f21d23","Node name for S&R":"UnloadLLMModelNode"},"widgets_values":[false]}],"links":[[3,5,0,2,0,"*"],[4,5,0,6,0,"STRING"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.8592411862551096,"offset":[-1632.828892161161,-142.36492093509267]},"workflowRendererVersion":"LG"},"version":0.4}
|
||||
+975
-67
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-llm-session"
|
||||
description = "Local LLM session nodes for ComfyUI using GGUF and llama.cpp, supporting Llama, Mistral, Qwen, DeepSeek, GLM, Gemma, Phi, LLaVA and gpt-oss, enabling both user-model chat and model-to-model dialogue without external runtimes like Ollama."
|
||||
version = "1.2.2"
|
||||
description = "Local LLM session nodes for ComfyUI that run GGUF models with llama.cpp without external runtimes like Ollama. Supports image analysis, audio analysis, user-model chat, and model-to-model dialogue across Llama, Mistral, Qwen, DeepSeek, GLM, Gemma, Phi, LLaVA, and gpt-oss with compatible backends."
|
||||
version = "1.6.0"
|
||||
license = {file = "LICENSE"}
|
||||
# classifiers = [
|
||||
# # For OS-independent nodes (works on all operating systems)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
addopts = --strict-config --strict-markers
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@ pillow>=10.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
# Note: Newer Vision/Qwen/Gemma model families can require a JamePeng llama-cpp-python build.
|
||||
# See: https://github.com/JamePeng/llama-cpp-python
|
||||
# See README.md and COMPATIBILITY.md for backend build notes.
|
||||
|
||||
# Note: ComfyUI and its dependencies (torch, etc.) are assumed to be installed
|
||||
|
||||
@@ -68,8 +68,12 @@ class DialogueCycleNodeExecutionRequest:
|
||||
stream_to_console: bool
|
||||
chat_handler_overrides: Optional[Dict[str, Dict[str, Any]]]
|
||||
text_chat_builder_overrides: Optional[Dict[str, Dict[str, Any]]]
|
||||
enable_thinking: bool = False
|
||||
reasoning_effort: str = "medium"
|
||||
advanced_generation_kwargs: Optional[Dict[str, Any]] = None
|
||||
advanced_summary_generation_kwargs: Optional[Dict[str, Any]] = None
|
||||
advanced_backend_kwargs: Optional[Dict[str, Any]] = None
|
||||
official_sampling_overrides: Optional[Dict[str, bool]] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -109,10 +113,14 @@ class DialogueCycleNodeExecutionService:
|
||||
runtime_cache=request.runtime_cache,
|
||||
log_level=request.log_level,
|
||||
suppress_backend_logs=request.suppress_backend_logs,
|
||||
enable_thinking=request.enable_thinking,
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
chat_handler_overrides=request.chat_handler_overrides,
|
||||
text_chat_builder_overrides=request.text_chat_builder_overrides,
|
||||
advanced_generation_kwargs=request.advanced_generation_kwargs,
|
||||
advanced_summary_generation_kwargs=request.advanced_summary_generation_kwargs,
|
||||
advanced_backend_kwargs=request.advanced_backend_kwargs,
|
||||
official_sampling_overrides=request.official_sampling_overrides,
|
||||
)
|
||||
dialogue_request = dependencies.build_dialogue_cycle_request(
|
||||
initial_user_text=request.initial_user_text,
|
||||
@@ -212,7 +220,7 @@ class ChatTurnService:
|
||||
transcript_lines.append(line0)
|
||||
|
||||
msg = first
|
||||
for _ in range(int(max(1, cycles))):
|
||||
for cycle_index in range(1, int(max(1, cycles)) + 1):
|
||||
sysA = (system_prompt_A or "").strip() or (system_prompt or "")
|
||||
lastA = chat_one_turn(
|
||||
user_text=msg,
|
||||
@@ -222,6 +230,7 @@ class ChatTurnService:
|
||||
reset_session=reset,
|
||||
stream_to_console=bool(stream_to_console),
|
||||
model_manager=managerA,
|
||||
log_prefix_override=f"[LLM Dialogue Cycle A/{cycle_index}]",
|
||||
**turn_kwargs_A,
|
||||
)
|
||||
lineA = f"[{now_iso()}] A: {lastA}"
|
||||
@@ -245,6 +254,7 @@ class ChatTurnService:
|
||||
reset_session=reset_for_b,
|
||||
stream_to_console=bool(stream_to_console),
|
||||
model_manager=managerB,
|
||||
log_prefix_override=f"[LLM Dialogue Cycle B/{cycle_index}]",
|
||||
**turn_kwargs_B,
|
||||
)
|
||||
lineB = f"[{now_iso()}] B: {lastB}"
|
||||
@@ -269,7 +279,3 @@ class ChatTurnService:
|
||||
|
||||
return "\n".join(transcript_lines)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
try:
|
||||
from ..core.logging_utils import get_module_logger, LOG_LEVEL_DEBUG
|
||||
except Exception:
|
||||
from core.logging_utils import get_module_logger, LOG_LEVEL_DEBUG
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationExecutionOutcome:
|
||||
@@ -37,23 +42,50 @@ class GenerationExecutionService:
|
||||
if not request.enable_attempt_logging:
|
||||
return None
|
||||
|
||||
def _attempt_details(
|
||||
*,
|
||||
token_limit: int,
|
||||
turns: Optional[int],
|
||||
elapsed: float,
|
||||
completion_tokens: Optional[int] = None,
|
||||
completion_tokens_estimated: bool = False,
|
||||
) -> str:
|
||||
parts = [f"token_limit={int(token_limit)}"]
|
||||
if completion_tokens is not None:
|
||||
token_label = "completion_tokens_est" if completion_tokens_estimated else "completion_tokens"
|
||||
tps_label = "tokens_per_second_est" if completion_tokens_estimated else "tokens_per_second"
|
||||
parts.append(f"{token_label}={int(completion_tokens)}")
|
||||
if elapsed > 0:
|
||||
parts.append(f"{tps_label}={float(completion_tokens) / float(elapsed):.2f}")
|
||||
parts.append(f"turns_limit={turns}")
|
||||
return ", ".join(parts)
|
||||
|
||||
def _attempt_logger(
|
||||
ok: bool,
|
||||
attempt_no: int,
|
||||
elapsed: float,
|
||||
gen_tok: int,
|
||||
token_limit: int,
|
||||
turns: Optional[int],
|
||||
err: Optional[Exception],
|
||||
completion_tokens: Optional[int] = None,
|
||||
completion_tokens_estimated: bool = False,
|
||||
) -> None:
|
||||
details = _attempt_details(
|
||||
token_limit=int(token_limit),
|
||||
turns=turns,
|
||||
elapsed=float(elapsed),
|
||||
completion_tokens=completion_tokens,
|
||||
completion_tokens_estimated=completion_tokens_estimated,
|
||||
)
|
||||
if ok:
|
||||
print(
|
||||
f"{request.log_prefix} Generation attempt {attempt_no} succeeded in {elapsed:.2f} seconds "
|
||||
f"(max_tokens={int(gen_tok)}, turns_limit={turns})"
|
||||
f"({details})"
|
||||
)
|
||||
return
|
||||
print(
|
||||
f"{request.log_prefix} Generation attempt {attempt_no} failed in {elapsed:.2f} seconds "
|
||||
f"(max_tokens={int(gen_tok)}, turns_limit={turns}): {err}"
|
||||
f"({details}): {err}"
|
||||
)
|
||||
|
||||
return _attempt_logger
|
||||
@@ -81,6 +113,20 @@ class GenerationExecutionService:
|
||||
|
||||
return _retry_error_logger
|
||||
|
||||
def _build_heartbeat_logger(self, request: Any) -> Optional[Callable[[float, int], None]]:
|
||||
if request.log_level != "debug" or bool(request.stream_to_console):
|
||||
return None
|
||||
module_logger = get_module_logger("TurnExecutionService")
|
||||
|
||||
def _heartbeat_logger(elapsed: float, _attempt_no: int) -> None:
|
||||
module_logger(
|
||||
f"{request.log_prefix} Generation still running... "
|
||||
f"elapsed={elapsed:.1f}s",
|
||||
LOG_LEVEL_DEBUG,
|
||||
)
|
||||
|
||||
return _heartbeat_logger
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
@@ -97,6 +143,16 @@ class GenerationExecutionService:
|
||||
run_generation_with_adaptive_retry = self._dep(deps, "run_generation_with_adaptive_retry")
|
||||
make_suppress_backend_logs = self._dep(deps, "make_suppress_backend_logs")
|
||||
attempt_logger = self._build_attempt_logger(request)
|
||||
heartbeat_logger = self._build_heartbeat_logger(request)
|
||||
if request.log_level == "debug":
|
||||
module_logger = get_module_logger("TurnExecutionService")
|
||||
module_logger(
|
||||
f"{request.log_prefix} Generation started: "
|
||||
f"stream_to_console={bool(request.stream_to_console)}, "
|
||||
f"max_tokens={int(request.max_tokens)}, "
|
||||
f"turns_limit={(int(request.max_turns) if request.max_turns is not None else None)}",
|
||||
LOG_LEVEL_DEBUG,
|
||||
)
|
||||
|
||||
generation_result = run_generation_with_adaptive_retry(
|
||||
llm=llm,
|
||||
@@ -132,6 +188,8 @@ class GenerationExecutionService:
|
||||
processing_interrupted=deps.get("processing_interrupted", lambda: False),
|
||||
throw_if_processing_interrupted=deps.get("throw_if_processing_interrupted", lambda: None),
|
||||
is_interrupt_error=deps.get("is_interrupt_error", lambda _err: False),
|
||||
heartbeat_logger=heartbeat_logger,
|
||||
heartbeat_interval=30.0,
|
||||
)
|
||||
|
||||
assistant_text = generation_result.assistant_text
|
||||
|
||||
@@ -23,6 +23,32 @@ class HistoryPersistenceService:
|
||||
raise KeyError(f"Missing dependency: {key}")
|
||||
return deps[key]
|
||||
|
||||
def _describe_media_inputs(self, media: Any) -> Dict[str, Any]:
|
||||
media_items = media if isinstance(media, (list, tuple)) else [media]
|
||||
image_count = 0
|
||||
audio_used = False
|
||||
for media_item in media_items:
|
||||
if media_item is None:
|
||||
continue
|
||||
if isinstance(media_item, dict) and "waveform" in media_item and "sample_rate" in media_item:
|
||||
audio_used = True
|
||||
continue
|
||||
shape = getattr(media_item, "shape", None)
|
||||
try:
|
||||
shape_tuple = tuple(int(x) for x in shape) if shape is not None else ()
|
||||
except Exception:
|
||||
shape_tuple = ()
|
||||
if len(shape_tuple) == 4 and shape_tuple[-1] in {1, 3, 4}:
|
||||
image_count += max(0, int(shape_tuple[0]))
|
||||
elif len(shape_tuple) == 3 and shape_tuple[-1] in {1, 3, 4}:
|
||||
image_count += 1
|
||||
return {
|
||||
"image_used": image_count > 0,
|
||||
"image_count": image_count,
|
||||
"audio_used": audio_used,
|
||||
"audio_format": "wav" if audio_used else "",
|
||||
}
|
||||
|
||||
def persist_history_and_summary(
|
||||
self,
|
||||
*,
|
||||
@@ -48,14 +74,22 @@ class HistoryPersistenceService:
|
||||
"dynamic_max_tokens": bool(request.dynamic_max_tokens),
|
||||
"max_tokens_used": int(generation_result.gen_tokens),
|
||||
"turns_limit_used": int(generation_result.turns_limit) if generation_result.turns_limit is not None else None,
|
||||
"reasoning_effort": str(getattr(request, "reasoning_effort", "medium") or "medium"),
|
||||
}
|
||||
if request.include_image_and_stream_in_turn_params:
|
||||
turn_params["image_used"] = request.image is not None
|
||||
official_sampling_profile = str(
|
||||
getattr(request, "official_sampling_profile", "") or ""
|
||||
)
|
||||
if official_sampling_profile:
|
||||
turn_params["official_sampling_profile"] = official_sampling_profile
|
||||
if request.include_media_and_stream_in_turn_params:
|
||||
turn_params.update(self._describe_media_inputs(request.media))
|
||||
turn_params["streamed"] = bool(request.stream_to_console)
|
||||
if isinstance(request.advanced_generation_kwargs, dict) and request.advanced_generation_kwargs:
|
||||
turn_params["advanced_generation_kwargs"] = dict(request.advanced_generation_kwargs)
|
||||
if isinstance(request.advanced_summary_generation_kwargs, dict) and request.advanced_summary_generation_kwargs:
|
||||
turn_params["advanced_summary_generation_kwargs"] = dict(request.advanced_summary_generation_kwargs)
|
||||
if isinstance(request.advanced_backend_kwargs, dict) and request.advanced_backend_kwargs:
|
||||
turn_params["advanced_backend_kwargs"] = dict(request.advanced_backend_kwargs)
|
||||
|
||||
history.setdefault("turns", []).append(
|
||||
{
|
||||
@@ -64,6 +98,7 @@ class HistoryPersistenceService:
|
||||
"user": {
|
||||
"text": request.user_text or "",
|
||||
"image_note": "",
|
||||
"audio_note": "",
|
||||
},
|
||||
"assistant": {
|
||||
"text": assistant_text or "",
|
||||
|
||||
@@ -26,6 +26,7 @@ class KvStateService:
|
||||
model_path: str,
|
||||
mmproj_path: Optional[str],
|
||||
clear_kv_state_for_session: Callable[[str], None],
|
||||
effective_system_prompt: Optional[str] = None,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
build_kv_state_signature = self._dep(deps, "build_kv_state_signature")
|
||||
try_restore_kv_state = self._dep(deps, "try_restore_kv_state")
|
||||
@@ -35,13 +36,13 @@ class KvStateService:
|
||||
kv_state_debug_info = self._dep(deps, "kv_state_debug_info")
|
||||
get_context_turns = self._dep(deps, "get_context_turns")
|
||||
|
||||
if (request.runtime_cache or "off") == "KV_cache" and request.image is None:
|
||||
if (request.runtime_cache or "off") == "KV_cache" and request.media is None:
|
||||
try:
|
||||
kv_sig = build_kv_state_signature(
|
||||
history=history,
|
||||
max_turns=request.max_turns,
|
||||
summarize_old_history=bool(request.summarize_old_history),
|
||||
system_prompt=(request.system_prompt or ""),
|
||||
system_prompt=(request.system_prompt or "") if effective_system_prompt is None else effective_system_prompt,
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
n_ctx=int(request.n_ctx),
|
||||
@@ -86,8 +87,9 @@ class KvStateService:
|
||||
mmproj_path: Optional[str],
|
||||
kv_state_debug_info: Any,
|
||||
get_context_turns: Any,
|
||||
effective_system_prompt: Optional[str] = None,
|
||||
) -> None:
|
||||
if (request.runtime_cache or "off") != "KV_cache" or request.image is not None:
|
||||
if (request.runtime_cache or "off") != "KV_cache" or request.media is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -96,7 +98,7 @@ class KvStateService:
|
||||
history=history,
|
||||
max_turns=request.max_turns,
|
||||
summarize_old_history=bool(request.summarize_old_history),
|
||||
system_prompt=(request.system_prompt or ""),
|
||||
system_prompt=(request.system_prompt or "") if effective_system_prompt is None else effective_system_prompt,
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
n_ctx=int(request.n_ctx),
|
||||
|
||||
@@ -10,8 +10,10 @@ try:
|
||||
from ..core.logging_utils import (
|
||||
log_error_safely,
|
||||
get_module_logger,
|
||||
set_module_log_level,
|
||||
LOG_LEVEL_MINIMAL,
|
||||
LOG_LEVEL_TIMING,
|
||||
LOG_LEVEL_DEBUG,
|
||||
)
|
||||
from .generation_execution_service import GenerationExecutionService
|
||||
from .kv_state_service import KvStateService
|
||||
@@ -20,8 +22,10 @@ except Exception:
|
||||
from core.logging_utils import (
|
||||
log_error_safely,
|
||||
get_module_logger,
|
||||
set_module_log_level,
|
||||
LOG_LEVEL_MINIMAL,
|
||||
LOG_LEVEL_TIMING,
|
||||
LOG_LEVEL_DEBUG,
|
||||
)
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.kv_state_service import KvStateService
|
||||
@@ -40,8 +44,10 @@ class TurnExecutionDependencies(TypedDict):
|
||||
rewrite_continue_prompt: Callable[..., Any]
|
||||
detect_history_language: Callable[[Dict[str, Any]], str]
|
||||
session_cache_root: Callable[[str, Optional[str]], str]
|
||||
validate_chat_media: Callable[..., None]
|
||||
build_chat_messages: Callable[..., List[Dict[str, Any]]]
|
||||
build_text_chat_request: Callable[..., Optional[Dict[str, Any]]]
|
||||
build_effective_system_prompt: Callable[..., str]
|
||||
build_kv_state_signature: Callable[..., str]
|
||||
try_restore_kv_state: Callable[..., None]
|
||||
is_state_data_mismatch_error: Callable[[Exception], bool]
|
||||
@@ -81,7 +87,7 @@ class TurnExecutionRequest:
|
||||
n_gpu_layers: int
|
||||
n_ctx: int
|
||||
tensor_split: Optional[List[float]] = None
|
||||
image: Any = None
|
||||
media: Any = None
|
||||
max_turns: Optional[int] = 12
|
||||
summarize_old_history: bool = True
|
||||
summary_chunk_turns: int = 3
|
||||
@@ -100,15 +106,20 @@ class TurnExecutionRequest:
|
||||
history_dir: str = ""
|
||||
reset_session: bool = False
|
||||
stream_to_console: bool = False
|
||||
enable_thinking: bool = False
|
||||
reasoning_effort: str = "medium"
|
||||
official_sampling_profile: str = ""
|
||||
model_manager: Optional[Any] = None
|
||||
chat_handler_overrides: Optional[Dict[str, Dict[str, Any]]] = None
|
||||
text_chat_builder_overrides: Optional[Dict[str, Dict[str, Any]]] = None
|
||||
advanced_generation_kwargs: Optional[Dict[str, Any]] = None
|
||||
advanced_summary_generation_kwargs: Optional[Dict[str, Any]] = None
|
||||
advanced_backend_kwargs: Optional[Dict[str, Any]] = None
|
||||
image_max_pixels: Optional[int] = None
|
||||
|
||||
# Behavior switches to preserve subtle differences between legacy call paths.
|
||||
strip_assistant_before_reasoning_filter: bool = False
|
||||
include_image_and_stream_in_turn_params: bool = True
|
||||
include_media_and_stream_in_turn_params: bool = True
|
||||
kv_log_saved_when_not_minimal: bool = False
|
||||
kv_log_unsupported_when_not_minimal: bool = False
|
||||
include_error_in_invalidate_message: bool = False
|
||||
@@ -135,7 +146,7 @@ class TurnExecutionRequest:
|
||||
n_gpu_layers: int,
|
||||
n_ctx: int,
|
||||
tensor_split: Optional[List[float]] = None,
|
||||
image: Any,
|
||||
media: Any,
|
||||
max_turns: Optional[int],
|
||||
summarize_old_history: bool,
|
||||
summary_chunk_turns: int,
|
||||
@@ -154,13 +165,18 @@ class TurnExecutionRequest:
|
||||
history_dir: str,
|
||||
reset_session: bool,
|
||||
stream_to_console: bool,
|
||||
enable_thinking: bool = False,
|
||||
reasoning_effort: str = "medium",
|
||||
official_sampling_profile: str = "",
|
||||
model_manager: Optional[Any],
|
||||
chat_handler_overrides: Optional[Dict[str, Dict[str, Any]]],
|
||||
text_chat_builder_overrides: Optional[Dict[str, Dict[str, Any]]],
|
||||
advanced_generation_kwargs: Optional[Dict[str, Any]] = None,
|
||||
advanced_summary_generation_kwargs: Optional[Dict[str, Any]] = None,
|
||||
advanced_backend_kwargs: Optional[Dict[str, Any]] = None,
|
||||
image_max_pixels: Optional[int] = None,
|
||||
strip_assistant_before_reasoning_filter: bool,
|
||||
include_image_and_stream_in_turn_params: bool,
|
||||
include_media_and_stream_in_turn_params: bool,
|
||||
kv_log_saved_when_not_minimal: bool,
|
||||
kv_log_unsupported_when_not_minimal: bool,
|
||||
include_error_in_invalidate_message: bool,
|
||||
@@ -180,7 +196,7 @@ class TurnExecutionRequest:
|
||||
n_gpu_layers=int(n_gpu_layers),
|
||||
n_ctx=int(n_ctx),
|
||||
tensor_split=(list(tensor_split) if tensor_split is not None else None),
|
||||
image=image,
|
||||
media=media,
|
||||
max_turns=(int(max_turns) if max_turns is not None else None),
|
||||
summarize_old_history=bool(summarize_old_history),
|
||||
summary_chunk_turns=int(summary_chunk_turns),
|
||||
@@ -199,6 +215,9 @@ class TurnExecutionRequest:
|
||||
history_dir=history_dir or "",
|
||||
reset_session=bool(reset_session),
|
||||
stream_to_console=bool(stream_to_console),
|
||||
enable_thinking=bool(enable_thinking),
|
||||
reasoning_effort=str(reasoning_effort or "medium"),
|
||||
official_sampling_profile=str(official_sampling_profile or ""),
|
||||
model_manager=model_manager,
|
||||
chat_handler_overrides=chat_handler_overrides,
|
||||
text_chat_builder_overrides=text_chat_builder_overrides,
|
||||
@@ -210,8 +229,12 @@ class TurnExecutionRequest:
|
||||
if isinstance(advanced_summary_generation_kwargs, dict)
|
||||
else None
|
||||
),
|
||||
advanced_backend_kwargs=(
|
||||
dict(advanced_backend_kwargs) if isinstance(advanced_backend_kwargs, dict) else None
|
||||
),
|
||||
image_max_pixels=(int(image_max_pixels) if image_max_pixels is not None else None),
|
||||
strip_assistant_before_reasoning_filter=bool(strip_assistant_before_reasoning_filter),
|
||||
include_image_and_stream_in_turn_params=bool(include_image_and_stream_in_turn_params),
|
||||
include_media_and_stream_in_turn_params=bool(include_media_and_stream_in_turn_params),
|
||||
kv_log_saved_when_not_minimal=bool(kv_log_saved_when_not_minimal),
|
||||
kv_log_unsupported_when_not_minimal=bool(kv_log_unsupported_when_not_minimal),
|
||||
include_error_in_invalidate_message=bool(include_error_in_invalidate_message),
|
||||
@@ -307,7 +330,7 @@ class TurnExecutionService:
|
||||
def execute_session_chat_turn(self, **kwargs: Any) -> TurnExecutionResult:
|
||||
return self.execute_from_node_inputs(
|
||||
strip_assistant_before_reasoning_filter=False,
|
||||
include_image_and_stream_in_turn_params=True,
|
||||
include_media_and_stream_in_turn_params=True,
|
||||
kv_log_saved_when_not_minimal=False,
|
||||
kv_log_unsupported_when_not_minimal=False,
|
||||
include_error_in_invalidate_message=False,
|
||||
@@ -317,14 +340,15 @@ class TurnExecutionService:
|
||||
)
|
||||
|
||||
def execute_dialogue_cycle_turn(self, **kwargs: Any) -> TurnExecutionResult:
|
||||
log_prefix = str(kwargs.pop("log_prefix_override", "[LLM Dialogue Cycle]") or "[LLM Dialogue Cycle]")
|
||||
return self.execute_from_node_inputs(
|
||||
strip_assistant_before_reasoning_filter=True,
|
||||
include_image_and_stream_in_turn_params=False,
|
||||
include_media_and_stream_in_turn_params=False,
|
||||
kv_log_saved_when_not_minimal=True,
|
||||
kv_log_unsupported_when_not_minimal=True,
|
||||
include_error_in_invalidate_message=True,
|
||||
enable_attempt_logging=False,
|
||||
log_prefix="[LLM Dialogue Cycle]",
|
||||
enable_attempt_logging=True,
|
||||
log_prefix=log_prefix,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -450,7 +474,12 @@ class TurnExecutionService:
|
||||
model_path: str,
|
||||
mmproj_path: Optional[str],
|
||||
hist_path: str,
|
||||
debug_timings: Optional[Dict[str, float]] = None,
|
||||
) -> tuple[Optional[Any], Optional[TurnExecutionResult]]:
|
||||
def _record(name: str, started_at: float) -> None:
|
||||
if debug_timings is not None:
|
||||
debug_timings[name] = time.perf_counter() - started_at
|
||||
|
||||
session_cache_root = self._dep(deps, "session_cache_root")
|
||||
try:
|
||||
mgr.cache_dir_override = session_cache_root(request.session_id, request.history_dir or None)
|
||||
@@ -469,9 +498,10 @@ class TurnExecutionService:
|
||||
mmproj_choice != mmproj_not_required
|
||||
and mmproj_choice_normalized not in {"autodetect", "auto", mmproj_auto_normalized}
|
||||
)
|
||||
vision_required = request.image is not None or explicit_mmproj_selected
|
||||
vision_required = request.media is not None or explicit_mmproj_selected
|
||||
|
||||
try:
|
||||
t_load_model = time.perf_counter()
|
||||
llm = mgr.load_model(
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
@@ -481,7 +511,9 @@ class TurnExecutionService:
|
||||
chat_handler_overrides=request.chat_handler_overrides,
|
||||
vision_required=vision_required,
|
||||
verbose=False,
|
||||
advanced_backend_kwargs=request.advanced_backend_kwargs,
|
||||
)
|
||||
_record("model_load", t_load_model)
|
||||
except Exception as err:
|
||||
return (
|
||||
None,
|
||||
@@ -494,6 +526,7 @@ class TurnExecutionService:
|
||||
)
|
||||
|
||||
try:
|
||||
t_cache_config = time.perf_counter()
|
||||
mgr.configure_cache(
|
||||
llm,
|
||||
model_path=model_path,
|
||||
@@ -504,11 +537,32 @@ class TurnExecutionService:
|
||||
persistent_cache=request.persistent_cache,
|
||||
runtime_cache=request.runtime_cache,
|
||||
)
|
||||
_record("cache_config", t_cache_config)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return llm, None
|
||||
|
||||
def _validate_media_input(
|
||||
self,
|
||||
*,
|
||||
request: TurnExecutionRequest,
|
||||
deps: TurnExecutionDependencies,
|
||||
model_path: str,
|
||||
hist_path: str,
|
||||
) -> Optional[TurnExecutionResult]:
|
||||
validate_chat_media = self._dep(deps, "validate_chat_media")
|
||||
try:
|
||||
validate_chat_media(media=request.media, model_path=model_path)
|
||||
except Exception as err:
|
||||
return TurnExecutionResult(
|
||||
assistant_text="",
|
||||
history_path=hist_path,
|
||||
generation_succeeded=False,
|
||||
error=err,
|
||||
)
|
||||
return None
|
||||
|
||||
def _build_generation_inputs(
|
||||
self,
|
||||
*,
|
||||
@@ -518,16 +572,19 @@ class TurnExecutionService:
|
||||
user_text_for_model: str,
|
||||
model_path: str,
|
||||
mmproj_path: Optional[str],
|
||||
effective_system_prompt: str,
|
||||
) -> tuple[Any, Any]:
|
||||
build_chat_messages = self._dep(deps, "build_chat_messages")
|
||||
build_text_chat_request = self._dep(deps, "build_text_chat_request")
|
||||
messages = build_chat_messages(
|
||||
history=history,
|
||||
user_text=user_text_for_model or "",
|
||||
image_tensor=request.image,
|
||||
media=request.media,
|
||||
model_path=model_path,
|
||||
max_turns=int(request.max_turns) if request.max_turns is not None else None,
|
||||
summarize_old_history=bool(request.summarize_old_history),
|
||||
system_prompt=request.system_prompt or "",
|
||||
system_prompt=effective_system_prompt,
|
||||
image_max_pixels=request.image_max_pixels,
|
||||
)
|
||||
text_chat_request = build_text_chat_request(
|
||||
model_path=model_path,
|
||||
@@ -548,6 +605,7 @@ class TurnExecutionService:
|
||||
model_path: str,
|
||||
mmproj_path: Optional[str],
|
||||
clear_kv_state_for_session: Any,
|
||||
effective_system_prompt: str,
|
||||
) -> tuple[Any, Any, Any]:
|
||||
return self._kv_state_service.restore_state(
|
||||
request=request,
|
||||
@@ -558,6 +616,7 @@ class TurnExecutionService:
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
clear_kv_state_for_session=clear_kv_state_for_session,
|
||||
effective_system_prompt=effective_system_prompt,
|
||||
)
|
||||
|
||||
def _persist_history_and_summary(
|
||||
@@ -596,6 +655,7 @@ class TurnExecutionService:
|
||||
mmproj_path: Optional[str],
|
||||
kv_state_debug_info: Any,
|
||||
get_context_turns: Any,
|
||||
effective_system_prompt: str,
|
||||
) -> None:
|
||||
self._kv_state_service.save_state(
|
||||
request=request,
|
||||
@@ -606,11 +666,26 @@ class TurnExecutionService:
|
||||
mmproj_path=mmproj_path,
|
||||
kv_state_debug_info=kv_state_debug_info,
|
||||
get_context_turns=get_context_turns,
|
||||
effective_system_prompt=effective_system_prompt,
|
||||
)
|
||||
|
||||
def execute_turn(self, request: TurnExecutionRequest) -> TurnExecutionResult:
|
||||
deps = request.dependencies
|
||||
set_module_log_level("TurnExecutionService", request.log_level)
|
||||
module_logger = get_module_logger("TurnExecutionService")
|
||||
debug_timings: Optional[Dict[str, float]] = {} if request.log_level == "debug" else None
|
||||
turn_started_at = time.perf_counter()
|
||||
|
||||
def _record_debug_timing(name: str, started_at: float) -> None:
|
||||
if debug_timings is not None:
|
||||
debug_timings[name] = time.perf_counter() - started_at
|
||||
|
||||
def _log_debug_timings(stage: str) -> None:
|
||||
if debug_timings is None:
|
||||
return
|
||||
parts = [f"{name}={elapsed:.2f}s" for name, elapsed in debug_timings.items()]
|
||||
parts.append(f"total={time.perf_counter() - turn_started_at:.2f}s")
|
||||
module_logger(f"{request.log_prefix} Timing ({stage}): " + ", ".join(parts), LOG_LEVEL_DEBUG)
|
||||
|
||||
mgr, early_result = self._preflight(request, deps)
|
||||
if early_result is not None:
|
||||
@@ -621,6 +696,16 @@ class TurnExecutionService:
|
||||
return early_result
|
||||
assert mgr is not None and model_path is not None
|
||||
|
||||
build_effective_system_prompt = deps.get("build_effective_system_prompt")
|
||||
effective_system_prompt = request.system_prompt or ""
|
||||
if callable(build_effective_system_prompt):
|
||||
effective_system_prompt = build_effective_system_prompt(
|
||||
model_path=model_path,
|
||||
system_prompt=request.system_prompt or "",
|
||||
enable_thinking=bool(request.enable_thinking),
|
||||
reasoning_effort=request.reasoning_effort,
|
||||
)
|
||||
|
||||
model_sig = self._build_model_sig(
|
||||
deps=deps,
|
||||
model_path=model_path,
|
||||
@@ -629,9 +714,22 @@ class TurnExecutionService:
|
||||
n_gpu_layers=int(request.n_gpu_layers),
|
||||
tensor_split=request.tensor_split,
|
||||
)
|
||||
t_history_load = time.perf_counter()
|
||||
history, hist_path = self._load_history(request=request, deps=deps, model_sig=model_sig)
|
||||
_record_debug_timing("history_load", t_history_load)
|
||||
t_reset_rewrite = time.perf_counter()
|
||||
clear_kv_state_for_session = self._reset_state_if_needed(request=request, deps=deps, mgr=mgr)
|
||||
user_text_for_model = self._rewrite_user_text(request=request, deps=deps, history=history)
|
||||
_record_debug_timing("reset_rewrite", t_reset_rewrite)
|
||||
|
||||
early_result = self._validate_media_input(
|
||||
request=request,
|
||||
deps=deps,
|
||||
model_path=model_path,
|
||||
hist_path=hist_path,
|
||||
)
|
||||
if early_result is not None:
|
||||
return early_result
|
||||
|
||||
llm, early_result = self._load_model_with_cache(
|
||||
request=request,
|
||||
@@ -640,6 +738,7 @@ class TurnExecutionService:
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
hist_path=hist_path,
|
||||
debug_timings=debug_timings,
|
||||
)
|
||||
if early_result is not None:
|
||||
return early_result
|
||||
@@ -647,14 +746,25 @@ class TurnExecutionService:
|
||||
|
||||
build_chat_messages = self._dep(deps, "build_chat_messages")
|
||||
build_text_chat_request = self._dep(deps, "build_text_chat_request")
|
||||
messages, text_chat_request = self._build_generation_inputs(
|
||||
request=request,
|
||||
deps=deps,
|
||||
history=history,
|
||||
user_text_for_model=user_text_for_model,
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
)
|
||||
t_prompt_build = time.perf_counter()
|
||||
try:
|
||||
messages, text_chat_request = self._build_generation_inputs(
|
||||
request=request,
|
||||
deps=deps,
|
||||
history=history,
|
||||
user_text_for_model=user_text_for_model,
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
effective_system_prompt=effective_system_prompt,
|
||||
)
|
||||
except Exception as err:
|
||||
return TurnExecutionResult(
|
||||
assistant_text="",
|
||||
history_path=hist_path,
|
||||
generation_succeeded=False,
|
||||
error=err,
|
||||
)
|
||||
_record_debug_timing("prompt_build", t_prompt_build)
|
||||
|
||||
def _message_chars(msgs: Any) -> int:
|
||||
total = 0
|
||||
@@ -691,6 +801,7 @@ class TurnExecutionService:
|
||||
|
||||
_log_prompt_metrics("Prompt metrics (initial)", request.max_turns, messages, text_chat_request)
|
||||
|
||||
t_kv_restore = time.perf_counter()
|
||||
is_state_data_mismatch_error, kv_state_debug_info, get_context_turns = self._maybe_restore_kv_state(
|
||||
request=request,
|
||||
deps=deps,
|
||||
@@ -700,7 +811,10 @@ class TurnExecutionService:
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
clear_kv_state_for_session=clear_kv_state_for_session,
|
||||
effective_system_prompt=effective_system_prompt,
|
||||
)
|
||||
_record_debug_timing("kv_restore", t_kv_restore)
|
||||
_log_debug_timings("before generation")
|
||||
|
||||
maybe_compact_summary = self._dep(deps, "maybe_compact_summary")
|
||||
|
||||
@@ -738,10 +852,12 @@ class TurnExecutionService:
|
||||
rebuilt_messages = build_chat_messages(
|
||||
history=history,
|
||||
user_text=user_text_for_model or "",
|
||||
image_tensor=request.image,
|
||||
media=request.media,
|
||||
model_path=model_path,
|
||||
max_turns=new_turns_limit,
|
||||
summarize_old_history=bool(request.summarize_old_history),
|
||||
system_prompt=request.system_prompt or "",
|
||||
system_prompt=effective_system_prompt,
|
||||
image_max_pixels=request.image_max_pixels,
|
||||
)
|
||||
rebuilt_text_chat_request = build_text_chat_request(
|
||||
model_path=model_path,
|
||||
@@ -752,6 +868,7 @@ class TurnExecutionService:
|
||||
_log_prompt_metrics("Prompt metrics (retry rebuild)", new_turns_limit, rebuilt_messages, rebuilt_text_chat_request)
|
||||
return rebuilt_messages, rebuilt_text_chat_request
|
||||
|
||||
t_generation = time.perf_counter()
|
||||
generation_outcome = self._generation_execution_service.execute(
|
||||
request=request,
|
||||
deps=deps,
|
||||
@@ -763,8 +880,10 @@ class TurnExecutionService:
|
||||
on_compact_summary=_on_compact_summary,
|
||||
rebuild_messages_for_turns_limit=_rebuild_messages_for_turns_limit,
|
||||
)
|
||||
_record_debug_timing("generation", t_generation)
|
||||
|
||||
if generation_outcome.failed:
|
||||
_log_debug_timings("failed")
|
||||
return TurnExecutionResult(
|
||||
assistant_text="",
|
||||
history_path=hist_path,
|
||||
@@ -774,6 +893,7 @@ class TurnExecutionService:
|
||||
|
||||
assistant_text = generation_outcome.assistant_text
|
||||
|
||||
t_persist = time.perf_counter()
|
||||
persistence_result = self._persist_history_and_summary(
|
||||
request=request,
|
||||
deps=deps,
|
||||
@@ -785,7 +905,9 @@ class TurnExecutionService:
|
||||
model_path=model_path,
|
||||
mmproj_path=mmproj_path,
|
||||
)
|
||||
_record_debug_timing("persist", t_persist)
|
||||
history = persistence_result.history
|
||||
t_kv_save = time.perf_counter()
|
||||
self._maybe_save_kv_state(
|
||||
request=request,
|
||||
deps=deps,
|
||||
@@ -795,7 +917,10 @@ class TurnExecutionService:
|
||||
mmproj_path=mmproj_path,
|
||||
kv_state_debug_info=kv_state_debug_info,
|
||||
get_context_turns=get_context_turns,
|
||||
effective_system_prompt=effective_system_prompt,
|
||||
)
|
||||
_record_debug_timing("kv_save", t_kv_save)
|
||||
_log_debug_timings("complete")
|
||||
|
||||
return TurnExecutionResult(
|
||||
assistant_text=assistant_text,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pathlib
|
||||
import sys
|
||||
import types
|
||||
@@ -11,6 +12,9 @@ import pytest
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
SERVICES_TESTS = ROOT / "tests" / "services"
|
||||
if str(SERVICES_TESTS) not in sys.path:
|
||||
sys.path.insert(0, str(SERVICES_TESTS))
|
||||
|
||||
if "folder_paths" not in sys.modules:
|
||||
sys.modules["folder_paths"] = types.SimpleNamespace(
|
||||
@@ -41,3 +45,34 @@ def reset_logging_state():
|
||||
set_global_log_level(original_global)
|
||||
mls.clear()
|
||||
mls.update(original_module)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_nodes_module(monkeypatch):
|
||||
"""Import llm_session_nodes with a stable ComfyUI folder_paths stub."""
|
||||
|
||||
def _load(*, available_models=None, available_mmprojs=None):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
module = importlib.import_module("llm_session_nodes")
|
||||
if available_models is not None or available_mmprojs is not None:
|
||||
models = ["dummy.gguf"] if available_models is None else available_models
|
||||
mmprojs = (
|
||||
[module._MMPROJ_AUTO, module._MMPROJ_NOT_REQUIRED]
|
||||
if available_mmprojs is None
|
||||
else available_mmprojs
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_get_available_models_and_mmprojs",
|
||||
lambda: (list(models), list(mmprojs)),
|
||||
)
|
||||
return module
|
||||
|
||||
return _load
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import threading
|
||||
|
||||
import core.generation_runner as generation_runner
|
||||
from core.generation_runner import run_generation_with_adaptive_retry, run_with_typeerror_fallback
|
||||
|
||||
|
||||
@@ -30,12 +32,60 @@ def test_run_with_typeerror_fallback_retries_then_succeeds() -> None:
|
||||
assert attempts["n"] == 3
|
||||
|
||||
|
||||
def test_run_with_typeerror_fallback_drops_only_rejected_advanced_kwarg() -> None:
|
||||
observed = []
|
||||
|
||||
def _execute(kwargs):
|
||||
observed.append(dict(kwargs))
|
||||
if "min_p" in kwargs:
|
||||
raise TypeError("unexpected keyword argument 'min_p'")
|
||||
return kwargs
|
||||
|
||||
result = run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_execute,
|
||||
completion_kwargs={"top_k": 40, "min_p": 0.05, "present_penalty": 0.0},
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
repeat_last_n=0,
|
||||
)
|
||||
|
||||
assert result == {"top_k": 40, "present_penalty": 0.0}
|
||||
assert observed == [
|
||||
{"top_k": 40, "min_p": 0.05, "present_penalty": 0.0},
|
||||
{"top_k": 40, "present_penalty": 0.0},
|
||||
]
|
||||
|
||||
|
||||
def test_run_with_typeerror_fallback_does_not_drop_advanced_kwargs_for_unrelated_error() -> None:
|
||||
attempts = {"n": 0}
|
||||
|
||||
def _execute(_kwargs):
|
||||
attempts["n"] += 1
|
||||
raise TypeError("internal sampler failure")
|
||||
|
||||
try:
|
||||
run_with_typeerror_fallback(
|
||||
execute_with_kwargs=_execute,
|
||||
completion_kwargs={"top_k": 40, "min_p": 0.05},
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
repeat_last_n=0,
|
||||
)
|
||||
except TypeError as error:
|
||||
assert str(error) == "internal sampler failure"
|
||||
else:
|
||||
raise AssertionError("Expected unrelated TypeError to propagate")
|
||||
|
||||
assert attempts["n"] == 3
|
||||
|
||||
|
||||
def test_generation_success_first_attempt() -> None:
|
||||
observed_kwargs = {}
|
||||
|
||||
def create_chat_completion_robust(_llm, _messages, **_kwargs):
|
||||
observed_kwargs.update(_kwargs)
|
||||
return {"choices": [{"message": {"content": "ok"}}]}
|
||||
return {
|
||||
"choices": [{"message": {"content": "ok"}}],
|
||||
"usage": {"completion_tokens": 7},
|
||||
}
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
@@ -65,13 +115,195 @@ def test_generation_success_first_attempt() -> None:
|
||||
create_chat_completion_robust=create_chat_completion_robust,
|
||||
extract_stream_content=lambda _chunk: "",
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
advanced_generation_kwargs={"seed": 123},
|
||||
advanced_generation_kwargs={
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.assistant_text == "ok"
|
||||
assert result.non_ctx_error is False
|
||||
assert result.completion_tokens == 7
|
||||
assert result.completion_tokens_estimated is False
|
||||
assert observed_kwargs["seed"] == 123
|
||||
assert observed_kwargs["top_k"] == 20
|
||||
assert observed_kwargs["min_p"] == 0.0
|
||||
assert observed_kwargs["present_penalty"] == 1.5
|
||||
|
||||
|
||||
def test_generation_estimates_completion_tokens_when_usage_is_missing() -> None:
|
||||
class FakeLlama:
|
||||
def tokenize(self, data, add_bos=False):
|
||||
assert add_bos is False
|
||||
assert data == b"ok"
|
||||
return [1, 2]
|
||||
|
||||
def create_chat_completion_robust(_llm, _messages, **_kwargs):
|
||||
return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=FakeLlama(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=False,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=5,
|
||||
stream_to_console=False,
|
||||
max_attempts=3,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _t: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(()),
|
||||
create_chat_completion_robust=create_chat_completion_robust,
|
||||
extract_stream_content=lambda _chunk: "",
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.completion_tokens == 2
|
||||
assert result.completion_tokens_estimated is True
|
||||
|
||||
|
||||
def test_generation_succeeds_without_completion_tokens_when_tokenize_is_missing() -> None:
|
||||
def create_chat_completion_robust(_llm, _messages, **_kwargs):
|
||||
return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=False,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=5,
|
||||
stream_to_console=False,
|
||||
max_attempts=3,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _t: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(()),
|
||||
create_chat_completion_robust=create_chat_completion_robust,
|
||||
extract_stream_content=lambda _chunk: "",
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.completion_tokens is None
|
||||
assert result.completion_tokens_estimated is False
|
||||
|
||||
|
||||
def test_stream_to_console_adds_separator_newline_after_unterminated_output(monkeypatch) -> None:
|
||||
output = io.StringIO()
|
||||
monkeypatch.setattr(generation_runner.sys, "__stdout__", output)
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=False,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=5,
|
||||
stream_to_console=True,
|
||||
max_attempts=3,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _t: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(
|
||||
[
|
||||
{"choices": [{"delta": {"content": "hello"}}]},
|
||||
{"choices": [{"delta": {"content": " world"}}]},
|
||||
]
|
||||
),
|
||||
create_chat_completion_robust=lambda *_a, **_k: {"choices": [{"message": {"content": "unused"}}]},
|
||||
extract_stream_content=lambda chunk: chunk["choices"][0]["delta"]["content"],
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.assistant_text == "hello world"
|
||||
assert output.getvalue() == "hello world\n"
|
||||
|
||||
|
||||
def test_stream_to_console_does_not_add_extra_newline_after_terminated_output(monkeypatch) -> None:
|
||||
output = io.StringIO()
|
||||
monkeypatch.setattr(generation_runner.sys, "__stdout__", output)
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=False,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=5,
|
||||
stream_to_console=True,
|
||||
max_attempts=3,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _t: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(
|
||||
[
|
||||
{"choices": [{"delta": {"content": "hello"}}]},
|
||||
{"choices": [{"delta": {"content": "\n"}}]},
|
||||
]
|
||||
),
|
||||
create_chat_completion_robust=lambda *_a, **_k: {"choices": [{"message": {"content": "unused"}}]},
|
||||
extract_stream_content=lambda chunk: chunk["choices"][0]["delta"]["content"],
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.assistant_text == "hello\n"
|
||||
assert output.getvalue() == "hello\n"
|
||||
|
||||
|
||||
def test_generation_retries_on_ctx_error_with_reduced_tokens() -> None:
|
||||
@@ -271,3 +503,98 @@ def test_generation_abort_watcher_calls_llm_abort_and_reraises_interrupt() -> No
|
||||
assert llm.abort_called is True
|
||||
else:
|
||||
raise AssertionError("Interrupt error should be re-raised")
|
||||
|
||||
|
||||
def test_generation_heartbeat_runs_while_non_stream_generation_is_blocked() -> None:
|
||||
heartbeat_seen = threading.Event()
|
||||
|
||||
def heartbeat_logger(_elapsed: float, attempt_no: int) -> None:
|
||||
assert attempt_no == 1
|
||||
heartbeat_seen.set()
|
||||
|
||||
def create_chat_completion_robust(_llm, _messages, **_kwargs):
|
||||
assert heartbeat_seen.wait(timeout=2.0)
|
||||
return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=True,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=6,
|
||||
stream_to_console=False,
|
||||
max_attempts=4,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _turns: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(()),
|
||||
create_chat_completion_robust=create_chat_completion_robust,
|
||||
extract_stream_content=lambda _chunk: "",
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
heartbeat_logger=heartbeat_logger,
|
||||
heartbeat_interval=0.01,
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.assistant_text == "ok"
|
||||
assert heartbeat_seen.is_set()
|
||||
|
||||
|
||||
def test_generation_heartbeat_logger_errors_do_not_fail_generation() -> None:
|
||||
heartbeat_seen = threading.Event()
|
||||
|
||||
def heartbeat_logger(_elapsed: float, _attempt_no: int) -> None:
|
||||
heartbeat_seen.set()
|
||||
raise RuntimeError("heartbeat failed")
|
||||
|
||||
def create_chat_completion_robust(_llm, _messages, **_kwargs):
|
||||
assert heartbeat_seen.wait(timeout=2.0)
|
||||
return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
result = run_generation_with_adaptive_retry(
|
||||
llm=object(),
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
text_chat_request=None,
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
repeat_penalty=1.1,
|
||||
repeat_last_n=64,
|
||||
dynamic_max_tokens=True,
|
||||
min_generation_tokens=32,
|
||||
safety_margin_tokens=8,
|
||||
initial_turns_limit=6,
|
||||
stream_to_console=False,
|
||||
max_attempts=4,
|
||||
is_ctx_error=lambda _e: False,
|
||||
is_state_data_mismatch_error=lambda _e: False,
|
||||
on_state_cache_mismatch=lambda _e: None,
|
||||
on_compact_summary=lambda: None,
|
||||
rebuild_messages_for_turns_limit=lambda _turns: ([{"role": "user", "content": "hello"}], None),
|
||||
attempt_logger=None,
|
||||
debug_traceback=False,
|
||||
traceback_print_exc=lambda: None,
|
||||
suppress_backend_logs_ctx_factory=contextlib.nullcontext,
|
||||
iter_chat_completion_robust=lambda *_a, **_k: iter(()),
|
||||
create_chat_completion_robust=create_chat_completion_robust,
|
||||
extract_stream_content=lambda _chunk: "",
|
||||
retry_kwargs_with_repeat_last_n_fallback=lambda kwargs, _n: dict(kwargs),
|
||||
heartbeat_logger=heartbeat_logger,
|
||||
heartbeat_interval=0.01,
|
||||
)
|
||||
|
||||
assert result.succeeded is True
|
||||
assert result.assistant_text == "ok"
|
||||
|
||||
@@ -69,6 +69,7 @@ def test_run_dialogue_cycle_writes_transcript_and_unloads_non_kv_cache() -> None
|
||||
def test_run_dialogue_cycle_reset_session_applies_only_first_round() -> None:
|
||||
service = ChatTurnService()
|
||||
reset_flags: list[bool] = []
|
||||
log_prefixes: list[str] = []
|
||||
|
||||
managers = [DummyManager("A"), DummyManager("B")]
|
||||
|
||||
@@ -77,6 +78,7 @@ def test_run_dialogue_cycle_reset_session_applies_only_first_round() -> None:
|
||||
|
||||
def _chat_one_turn(**kwargs):
|
||||
reset_flags.append(bool(kwargs["reset_session"]))
|
||||
log_prefixes.append(str(kwargs["log_prefix_override"]))
|
||||
return "ok"
|
||||
|
||||
service.run_dialogue_cycle(
|
||||
@@ -102,6 +104,12 @@ def test_run_dialogue_cycle_reset_session_applies_only_first_round() -> None:
|
||||
)
|
||||
|
||||
assert reset_flags == [True, True, False, False]
|
||||
assert log_prefixes == [
|
||||
"[LLM Dialogue Cycle A/1]",
|
||||
"[LLM Dialogue Cycle B/1]",
|
||||
"[LLM Dialogue Cycle A/2]",
|
||||
"[LLM Dialogue Cycle B/2]",
|
||||
]
|
||||
|
||||
|
||||
def test_run_dialogue_cycle_kv_cache_skips_in_loop_unload() -> None:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from services.history_persistence_service import HistoryPersistenceService
|
||||
|
||||
|
||||
class FakeImageBatch:
|
||||
shape = (2, 4, 4, 3)
|
||||
|
||||
|
||||
def _request(**overrides):
|
||||
values = {
|
||||
"max_tokens": 128,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
"repeat_penalty": 1.1,
|
||||
"repeat_last_n": 64,
|
||||
"dynamic_max_tokens": True,
|
||||
"include_media_and_stream_in_turn_params": True,
|
||||
"media": None,
|
||||
"stream_to_console": False,
|
||||
"advanced_generation_kwargs": {},
|
||||
"advanced_summary_generation_kwargs": {},
|
||||
"advanced_backend_kwargs": {},
|
||||
"user_text": "hello",
|
||||
"persistent_cache": "off",
|
||||
"runtime_cache": "off",
|
||||
"max_turns": 12,
|
||||
"summarize_old_history": False,
|
||||
"summary_chunk_turns": 3,
|
||||
"max_tokens_summary": 256,
|
||||
"summary_max_chars": 1200,
|
||||
"system_prompt": "sys",
|
||||
"log_level": "timing",
|
||||
"text_chat_builder_overrides": {},
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _generation_result(**overrides):
|
||||
values = {"gen_tokens": 11, "turns_limit": 4}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _deps(*, writes=None, summarize_calls=None):
|
||||
writes = writes if writes is not None else []
|
||||
summarize_calls = summarize_calls if summarize_calls is not None else []
|
||||
|
||||
def _maybe_summarize_history(**kwargs):
|
||||
summarize_calls.append(kwargs)
|
||||
history = dict(kwargs["history"])
|
||||
history["summary"] = {"enabled": True, "text": "summary"}
|
||||
return history
|
||||
|
||||
return {
|
||||
"next_turn_id": lambda history: len(history.get("turns", [])) + 1,
|
||||
"now_iso": lambda: "2026-07-05T12:00:00+09:00",
|
||||
"maybe_summarize_history": _maybe_summarize_history,
|
||||
"atomic_write_json": lambda path, history: writes.append((path, history)),
|
||||
}
|
||||
|
||||
|
||||
def test_persist_history_appends_turn_and_writes_file() -> None:
|
||||
writes = []
|
||||
service = HistoryPersistenceService()
|
||||
|
||||
result = service.persist_history_and_summary(
|
||||
request=_request(
|
||||
media=FakeImageBatch(),
|
||||
stream_to_console=True,
|
||||
advanced_generation_kwargs={"seed": 123},
|
||||
advanced_backend_kwargs={"n_ubatch": 1024},
|
||||
official_sampling_profile="qwen3.8-thinking",
|
||||
),
|
||||
deps=_deps(writes=writes),
|
||||
history={"turns": []},
|
||||
assistant_text="answer",
|
||||
generation_result=_generation_result(),
|
||||
llm=object(),
|
||||
hist_path="history.json",
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
)
|
||||
|
||||
assert result.persistence_succeeded is True
|
||||
assert result.persistence_error is None
|
||||
assert writes == [("history.json", result.history)]
|
||||
turn = result.history["turns"][0]
|
||||
assert turn["id"] == 1
|
||||
assert turn["user"]["text"] == "hello"
|
||||
assert turn["assistant"]["text"] == "answer"
|
||||
assert turn["params"]["image_used"] is True
|
||||
assert turn["params"]["image_count"] == 2
|
||||
assert turn["params"]["streamed"] is True
|
||||
assert turn["params"]["advanced_generation_kwargs"] == {"seed": 123}
|
||||
assert turn["params"]["advanced_backend_kwargs"] == {"n_ubatch": 1024}
|
||||
assert turn["params"]["official_sampling_profile"] == "qwen3.8-thinking"
|
||||
assert result.history["meta"]["last_params"]["runtime_cache"] == "off"
|
||||
assert result.history["system_prompt"] == "sys"
|
||||
|
||||
|
||||
def test_persist_history_summarizes_before_write_when_enabled() -> None:
|
||||
writes = []
|
||||
summarize_calls = []
|
||||
service = HistoryPersistenceService()
|
||||
|
||||
result = service.persist_history_and_summary(
|
||||
request=_request(
|
||||
summarize_old_history=True,
|
||||
max_turns=2,
|
||||
log_level="debug",
|
||||
advanced_summary_generation_kwargs={"seed": 456},
|
||||
),
|
||||
deps=_deps(writes=writes, summarize_calls=summarize_calls),
|
||||
history={"turns": []},
|
||||
assistant_text="answer",
|
||||
generation_result=_generation_result(turns_limit=None),
|
||||
llm="llm",
|
||||
hist_path="history.json",
|
||||
model_path="model.gguf",
|
||||
mmproj_path="vision.mmproj",
|
||||
)
|
||||
|
||||
assert result.persistence_succeeded is True
|
||||
assert result.history["summary"] == {"enabled": True, "text": "summary"}
|
||||
assert writes == [("history.json", result.history)]
|
||||
assert summarize_calls[0]["model"] == "llm"
|
||||
assert summarize_calls[0]["max_turns"] == 2
|
||||
assert summarize_calls[0]["suppress_logs"] is False
|
||||
assert summarize_calls[0]["mmproj_path"] == "vision.mmproj"
|
||||
assert summarize_calls[0]["advanced_generation_kwargs"] == {"seed": 456}
|
||||
|
||||
|
||||
def test_persist_history_reports_write_failure_without_losing_history() -> None:
|
||||
service = HistoryPersistenceService()
|
||||
failure = OSError("locked")
|
||||
|
||||
def _raise_write(_path, _history):
|
||||
raise failure
|
||||
|
||||
deps = _deps()
|
||||
deps["atomic_write_json"] = _raise_write
|
||||
|
||||
result = service.persist_history_and_summary(
|
||||
request=_request(),
|
||||
deps=deps,
|
||||
history={"turns": []},
|
||||
assistant_text="answer",
|
||||
generation_result=_generation_result(),
|
||||
llm=object(),
|
||||
hist_path="history.json",
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
)
|
||||
|
||||
assert result.persistence_succeeded is False
|
||||
assert result.persistence_error is failure
|
||||
assert result.history["turns"][0]["assistant"]["text"] == "answer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("media", "expected"),
|
||||
[
|
||||
(None, {"image_used": False, "image_count": 0, "audio_used": False}),
|
||||
(FakeImageBatch(), {"image_used": True, "image_count": 2, "audio_used": False}),
|
||||
(
|
||||
{"waveform": object(), "sample_rate": 16000},
|
||||
{"image_used": False, "image_count": 0, "audio_used": True},
|
||||
),
|
||||
(
|
||||
(FakeImageBatch(), {"waveform": object(), "sample_rate": 16000}),
|
||||
{"image_used": True, "image_count": 2, "audio_used": True},
|
||||
),
|
||||
(object(), {"image_used": False, "image_count": 0, "audio_used": False}),
|
||||
],
|
||||
)
|
||||
def test_persist_history_records_media_description(media, expected) -> None:
|
||||
service = HistoryPersistenceService()
|
||||
|
||||
result = service.persist_history_and_summary(
|
||||
request=_request(media=media),
|
||||
deps=_deps(),
|
||||
history={"turns": []},
|
||||
assistant_text="answer",
|
||||
generation_result=_generation_result(),
|
||||
llm=object(),
|
||||
hist_path="history.json",
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
)
|
||||
|
||||
params = result.history["turns"][0]["params"]
|
||||
for key, value in expected.items():
|
||||
if isinstance(value, bool):
|
||||
assert params[key] is value
|
||||
else:
|
||||
assert params[key] == value
|
||||
|
||||
|
||||
def test_persist_history_requires_dependencies() -> None:
|
||||
service = HistoryPersistenceService()
|
||||
|
||||
with pytest.raises(KeyError, match="next_turn_id"):
|
||||
service.persist_history_and_summary(
|
||||
request=_request(),
|
||||
deps={},
|
||||
history={"turns": []},
|
||||
assistant_text="answer",
|
||||
generation_result=_generation_result(),
|
||||
llm=object(),
|
||||
hist_path="history.json",
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
)
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from services.kv_state_service import KvStateService
|
||||
|
||||
|
||||
class DummyManager:
|
||||
def __init__(self, *, fail_invalidate: bool = False) -> None:
|
||||
self.fail_invalidate = fail_invalidate
|
||||
self.invalidate_calls = []
|
||||
|
||||
def invalidate_cache(self, llm, *, remove_disk_data: bool):
|
||||
self.invalidate_calls.append((llm, remove_disk_data))
|
||||
if self.fail_invalidate:
|
||||
raise OSError("cannot invalidate")
|
||||
|
||||
|
||||
def _request(**overrides):
|
||||
values = {
|
||||
"runtime_cache": "KV_cache",
|
||||
"media": None,
|
||||
"max_turns": 4,
|
||||
"summarize_old_history": True,
|
||||
"system_prompt": "sys",
|
||||
"n_ctx": 4096,
|
||||
"n_gpu_layers": 12,
|
||||
"tensor_split": "1,1",
|
||||
"session_id": "sid",
|
||||
"log_prefix": "[Test]",
|
||||
"log_level": "timing",
|
||||
"include_error_in_invalidate_message": True,
|
||||
"kv_log_saved_when_not_minimal": True,
|
||||
"kv_log_unsupported_when_not_minimal": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def _deps(*, restore_calls=None, save_calls=None):
|
||||
restore_calls = restore_calls if restore_calls is not None else []
|
||||
save_calls = save_calls if save_calls is not None else []
|
||||
|
||||
def _build_kv_state_signature(**kwargs):
|
||||
return "sig:" + kwargs["model_path"]
|
||||
|
||||
return {
|
||||
"build_kv_state_signature": _build_kv_state_signature,
|
||||
"try_restore_kv_state": lambda **kwargs: restore_calls.append(kwargs),
|
||||
"try_save_kv_state": lambda **kwargs: save_calls.append(kwargs),
|
||||
"is_state_data_mismatch_error": lambda err: "state" in str(err),
|
||||
"saved_llama_state_size": lambda _state: 10,
|
||||
"current_llama_state_size": lambda _llm: 10,
|
||||
"kv_state_debug_info": lambda _state: "debug",
|
||||
"get_context_turns": lambda history, max_turns=None: history.get("turns", []),
|
||||
"mem_kv_state": {},
|
||||
"cache_debug_label": lambda _mgr: "cache-label",
|
||||
}
|
||||
|
||||
|
||||
def test_restore_state_calls_try_restore_for_kv_cache() -> None:
|
||||
restore_calls = []
|
||||
deps = _deps(restore_calls=restore_calls)
|
||||
mgr = DummyManager()
|
||||
llm = object()
|
||||
cleared = []
|
||||
|
||||
callbacks = KvStateService().restore_state(
|
||||
request=_request(),
|
||||
deps=deps,
|
||||
mgr=mgr,
|
||||
llm=llm,
|
||||
history={"turns": [{"id": 1}]},
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
clear_kv_state_for_session=cleared.append,
|
||||
)
|
||||
|
||||
assert callbacks == (
|
||||
deps["is_state_data_mismatch_error"],
|
||||
deps["kv_state_debug_info"],
|
||||
deps["get_context_turns"],
|
||||
)
|
||||
assert len(restore_calls) == 1
|
||||
call = restore_calls[0]
|
||||
assert call["session_id"] == "sid"
|
||||
assert call["signature"] == "sig:model.gguf"
|
||||
assert call["llm"] is llm
|
||||
assert call["mem_kv_state"] is deps["mem_kv_state"]
|
||||
call["invalidate_cache"](llm, remove_disk_data=True)
|
||||
assert mgr.invalidate_calls == [(llm, True)]
|
||||
|
||||
|
||||
def test_restore_state_skips_try_restore_when_cache_off_or_media_present() -> None:
|
||||
for request in [_request(runtime_cache="off"), _request(media=object())]:
|
||||
restore_calls = []
|
||||
|
||||
KvStateService().restore_state(
|
||||
request=request,
|
||||
deps=_deps(restore_calls=restore_calls),
|
||||
mgr=DummyManager(),
|
||||
llm=object(),
|
||||
history={"turns": []},
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
clear_kv_state_for_session=lambda _sid: None,
|
||||
)
|
||||
|
||||
assert restore_calls == []
|
||||
|
||||
|
||||
def test_restore_state_swallows_restore_failure() -> None:
|
||||
def _raise_restore(**_kwargs):
|
||||
raise RuntimeError("restore failed")
|
||||
|
||||
deps = _deps()
|
||||
deps["try_restore_kv_state"] = _raise_restore
|
||||
|
||||
callbacks = KvStateService().restore_state(
|
||||
request=_request(),
|
||||
deps=deps,
|
||||
mgr=DummyManager(),
|
||||
llm=object(),
|
||||
history={"turns": []},
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
clear_kv_state_for_session=lambda _sid: None,
|
||||
)
|
||||
|
||||
assert callbacks[0] is deps["is_state_data_mismatch_error"]
|
||||
|
||||
|
||||
def test_save_state_calls_try_save_for_kv_cache() -> None:
|
||||
save_calls = []
|
||||
deps = _deps(save_calls=save_calls)
|
||||
llm = object()
|
||||
|
||||
KvStateService().save_state(
|
||||
request=_request(),
|
||||
deps=deps,
|
||||
llm=llm,
|
||||
history={"turns": [{"id": 1}]},
|
||||
model_path="model.gguf",
|
||||
mmproj_path="vision.mmproj",
|
||||
kv_state_debug_info=deps["kv_state_debug_info"],
|
||||
get_context_turns=deps["get_context_turns"],
|
||||
)
|
||||
|
||||
assert len(save_calls) == 1
|
||||
call = save_calls[0]
|
||||
assert call["session_id"] == "sid"
|
||||
assert call["signature"] == "sig:model.gguf"
|
||||
assert call["llm"] is llm
|
||||
assert call["log_saved_when_not_minimal"] is True
|
||||
assert call["log_unsupported_when_not_minimal"] is False
|
||||
|
||||
|
||||
def test_save_state_skips_when_cache_off_or_media_present() -> None:
|
||||
for request in [_request(runtime_cache="off"), _request(media=object())]:
|
||||
save_calls = []
|
||||
|
||||
KvStateService().save_state(
|
||||
request=request,
|
||||
deps=_deps(save_calls=save_calls),
|
||||
llm=object(),
|
||||
history={"turns": []},
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
kv_state_debug_info=lambda _state: "debug",
|
||||
get_context_turns=lambda history, max_turns=None: history.get("turns", []),
|
||||
)
|
||||
|
||||
assert save_calls == []
|
||||
|
||||
|
||||
def test_save_state_swallows_save_failure() -> None:
|
||||
def _raise_save(**_kwargs):
|
||||
raise RuntimeError("save failed")
|
||||
|
||||
deps = _deps()
|
||||
deps["try_save_kv_state"] = _raise_save
|
||||
|
||||
KvStateService().save_state(
|
||||
request=_request(),
|
||||
deps=deps,
|
||||
llm=object(),
|
||||
history={"turns": []},
|
||||
model_path="model.gguf",
|
||||
mmproj_path=None,
|
||||
kv_state_debug_info=lambda _state: "debug",
|
||||
get_context_turns=lambda history, max_turns=None: history.get("turns", []),
|
||||
)
|
||||
|
||||
|
||||
def test_on_state_cache_mismatch_clears_and_invalidates_cache(capsys) -> None:
|
||||
cleared = []
|
||||
mgr = DummyManager()
|
||||
llm = object()
|
||||
|
||||
KvStateService().on_state_cache_mismatch(
|
||||
request=_request(),
|
||||
deps=_deps(),
|
||||
clear_kv_state_for_session=cleared.append,
|
||||
mgr=mgr,
|
||||
llm=llm,
|
||||
err=RuntimeError("state mismatch"),
|
||||
)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert cleared == ["sid"]
|
||||
assert mgr.invalidate_calls == [(llm, True)]
|
||||
assert "cache-label" in output
|
||||
assert "cache invalidated" in output
|
||||
|
||||
|
||||
def test_on_state_cache_mismatch_swallows_invalidation_failure() -> None:
|
||||
cleared = []
|
||||
|
||||
KvStateService().on_state_cache_mismatch(
|
||||
request=_request(log_level="minimal"),
|
||||
deps=_deps(),
|
||||
clear_kv_state_for_session=cleared.append,
|
||||
mgr=DummyManager(fail_invalidate=True),
|
||||
llm=object(),
|
||||
err=RuntimeError("state mismatch"),
|
||||
)
|
||||
|
||||
assert cleared == ["sid"]
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import (
|
||||
SessionChatNodeExecutionDependencies,
|
||||
SessionChatNodeExecutionRequest,
|
||||
SessionChatNodeExecutionService,
|
||||
TurnExecutionRequest,
|
||||
TurnExecutionResult,
|
||||
TurnExecutionService,
|
||||
)
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
DummyLogRequest,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
def test_session_chat_node_execution_service_success_path() -> None:
|
||||
service = SessionChatNodeExecutionService()
|
||||
calls: dict[str, object] = {"logged": None, "executed": None}
|
||||
|
||||
request = SessionChatNodeExecutionRequest(
|
||||
model="model.gguf",
|
||||
turn_kwargs={"user_text": "hello"},
|
||||
)
|
||||
deps = SessionChatNodeExecutionDependencies(
|
||||
require_llama_cpp_available=lambda: None,
|
||||
resolve_valid_model_path=lambda _model, _start_time: "/models/model.gguf",
|
||||
get_or_create_model_manager=lambda: "mgr",
|
||||
execute_session_chat_turn=lambda **kwargs: (
|
||||
calls.update({"executed": kwargs})
|
||||
or TurnExecutionResult(assistant_text="ok", generation_succeeded=True)
|
||||
),
|
||||
session_chat_error_return=lambda _start, _msg=None: ("",),
|
||||
log_session_chat_total=lambda _start, status: calls.update({"logged": status}),
|
||||
)
|
||||
|
||||
result = service.run(request=request, dependencies=deps)
|
||||
|
||||
assert result == ("ok",)
|
||||
assert isinstance(calls["executed"], dict)
|
||||
assert calls["executed"]["model_manager"] == "mgr"
|
||||
assert calls["logged"] == "Finished"
|
||||
|
||||
|
||||
def test_session_chat_node_execution_service_failure_with_error_uses_error_return() -> None:
|
||||
service = SessionChatNodeExecutionService()
|
||||
error_calls: list[tuple[float, object]] = []
|
||||
|
||||
request = SessionChatNodeExecutionRequest(
|
||||
model="model.gguf",
|
||||
turn_kwargs={"user_text": "hello"},
|
||||
)
|
||||
deps = SessionChatNodeExecutionDependencies(
|
||||
require_llama_cpp_available=lambda: None,
|
||||
resolve_valid_model_path=lambda _model, _start_time: "/models/model.gguf",
|
||||
get_or_create_model_manager=lambda: "mgr",
|
||||
execute_session_chat_turn=lambda **_kwargs: TurnExecutionResult(
|
||||
assistant_text="",
|
||||
generation_succeeded=False,
|
||||
error=RuntimeError("boom"),
|
||||
),
|
||||
session_chat_error_return=lambda start, message=None: (
|
||||
error_calls.append((start, message)),
|
||||
("",),
|
||||
)[1],
|
||||
log_session_chat_total=lambda _start, _status: None,
|
||||
)
|
||||
|
||||
result = service.run(request=request, dependencies=deps)
|
||||
|
||||
assert result == ("",)
|
||||
assert error_calls
|
||||
assert "boom" in str(error_calls[0][1])
|
||||
|
||||
|
||||
def test_session_chat_node_execution_service_warns_on_persistence_failure(capsys) -> None:
|
||||
service = SessionChatNodeExecutionService()
|
||||
|
||||
request = SessionChatNodeExecutionRequest(
|
||||
model="model.gguf",
|
||||
turn_kwargs={"user_text": "hello"},
|
||||
)
|
||||
deps = SessionChatNodeExecutionDependencies(
|
||||
require_llama_cpp_available=lambda: None,
|
||||
resolve_valid_model_path=lambda _model, _start_time: "/models/model.gguf",
|
||||
get_or_create_model_manager=lambda: "mgr",
|
||||
execute_session_chat_turn=lambda **_kwargs: TurnExecutionResult(
|
||||
assistant_text="ok",
|
||||
generation_succeeded=True,
|
||||
persistence_succeeded=False,
|
||||
persistence_error=RuntimeError("disk full"),
|
||||
),
|
||||
session_chat_error_return=lambda _start, _msg=None: ("",),
|
||||
log_session_chat_total=lambda _start, _status: None,
|
||||
)
|
||||
|
||||
result = service.run(request=request, dependencies=deps)
|
||||
|
||||
assert result == ("ok",)
|
||||
captured = capsys.readouterr()
|
||||
assert "response generated but history was not saved" in captured.out
|
||||
assert "disk full" in captured.out
|
||||
|
||||
|
||||
def test_session_chat_node_execution_service_keeps_existing_model_manager() -> None:
|
||||
service = SessionChatNodeExecutionService()
|
||||
calls: dict[str, object] = {"factory_calls": 0, "executed": None}
|
||||
|
||||
request = SessionChatNodeExecutionRequest(
|
||||
model="model.gguf",
|
||||
turn_kwargs={"user_text": "hello", "model_manager": "existing"},
|
||||
)
|
||||
|
||||
def _factory():
|
||||
calls["factory_calls"] = int(calls["factory_calls"]) + 1
|
||||
return "new"
|
||||
|
||||
deps = SessionChatNodeExecutionDependencies(
|
||||
require_llama_cpp_available=lambda: None,
|
||||
resolve_valid_model_path=lambda _model, _start_time: "/models/model.gguf",
|
||||
get_or_create_model_manager=_factory,
|
||||
execute_session_chat_turn=lambda **kwargs: (
|
||||
calls.update({"executed": kwargs})
|
||||
or TurnExecutionResult(assistant_text="ok", generation_succeeded=True)
|
||||
),
|
||||
session_chat_error_return=lambda _start, _msg=None: ("",),
|
||||
log_session_chat_total=lambda _start, _status: None,
|
||||
)
|
||||
|
||||
result = service.run(request=request, dependencies=deps)
|
||||
|
||||
assert result == ("ok",)
|
||||
assert calls["factory_calls"] == 0
|
||||
assert calls["executed"]["model_manager"] == "existing"
|
||||
|
||||
|
||||
def test_session_chat_node_execution_service_stops_when_model_is_invalid() -> None:
|
||||
service = SessionChatNodeExecutionService()
|
||||
executed = {"called": False}
|
||||
|
||||
request = SessionChatNodeExecutionRequest(
|
||||
model="model.gguf",
|
||||
turn_kwargs={"user_text": "hello"},
|
||||
)
|
||||
deps = SessionChatNodeExecutionDependencies(
|
||||
require_llama_cpp_available=lambda: None,
|
||||
resolve_valid_model_path=lambda _model, _start_time: None,
|
||||
get_or_create_model_manager=lambda: "mgr",
|
||||
execute_session_chat_turn=lambda **_kwargs: (
|
||||
executed.update({"called": True})
|
||||
or TurnExecutionResult(assistant_text="ok", generation_succeeded=True)
|
||||
),
|
||||
session_chat_error_return=lambda _start, _msg=None: ("",),
|
||||
log_session_chat_total=lambda _start, _status: None,
|
||||
)
|
||||
result = service.run(request=request, dependencies=deps)
|
||||
|
||||
assert result == ("",)
|
||||
assert executed["called"] is False
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.turn_execution_service import TurnExecutionService
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
|
||||
|
||||
def _run_turn(advanced_backend_kwargs):
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed_generation = _capture_generation_kwargs(deps)
|
||||
request = replace(_make_request(deps, mgr), advanced_backend_kwargs=advanced_backend_kwargs)
|
||||
|
||||
result = TurnExecutionService().execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
return mgr, history, observed_generation
|
||||
|
||||
|
||||
def test_execute_turn_passes_backend_kwargs_to_model_load_only() -> None:
|
||||
mgr, history, observed_generation = _run_turn({"n_batch": 2048, "n_ubatch": 1024})
|
||||
|
||||
assert mgr.last_load_kwargs["advanced_backend_kwargs"] == {"n_batch": 2048, "n_ubatch": 1024}
|
||||
assert "n_ubatch" not in str(observed_generation)
|
||||
assert history["turns"][0]["params"]["advanced_backend_kwargs"] == {"n_batch": 2048, "n_ubatch": 1024}
|
||||
|
||||
|
||||
def test_execute_turn_does_not_record_unset_backend_kwargs() -> None:
|
||||
mgr, history, _observed_generation = _run_turn(None)
|
||||
|
||||
assert mgr.last_load_kwargs["advanced_backend_kwargs"] is None
|
||||
assert "advanced_backend_kwargs" not in history["turns"][0]["params"]
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import (
|
||||
SessionChatNodeExecutionDependencies,
|
||||
SessionChatNodeExecutionRequest,
|
||||
SessionChatNodeExecutionService,
|
||||
TurnExecutionRequest,
|
||||
TurnExecutionResult,
|
||||
TurnExecutionService,
|
||||
)
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
DummyLogRequest,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
class TestErrorHandlingP0P1:
|
||||
"""Tests for P0/P1 error handling improvements."""
|
||||
|
||||
def test_cache_invalidation_failure_during_reset_logs_error(self, caplog) -> None:
|
||||
"""P0: Test that cache invalidation failure during session reset is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
# Make invalidate_cache raise an exception
|
||||
def failing_invalidate(*_args, **_kwargs):
|
||||
raise RuntimeError("Cache invalidation failed")
|
||||
|
||||
mgr.invalidate_cache = failing_invalidate
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Create request with reset_session=True
|
||||
request = TurnExecutionRequest(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
runtime_cache="off",
|
||||
model_manager=mgr,
|
||||
dependencies=deps,
|
||||
reset_session=True,
|
||||
)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite cache invalidation failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_cache_directory_creation_failure_logs_error(self, caplog) -> None:
|
||||
"""P0: Test that cache directory creation failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make session_cache_root raise an exception
|
||||
deps["session_cache_root"] = lambda _sid, _dir: (_ for _ in ()).throw(RuntimeError("Cannot create cache dir"))
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
|
||||
# Should not raise, but should log the error and continue without cache
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite cache directory failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_history_save_failure_logs_error(self, caplog) -> None:
|
||||
"""P0: Test that history file save failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make atomic_write_json raise an exception
|
||||
deps["atomic_write_json"] = lambda _path, _obj: (_ for _ in ()).throw(RuntimeError("Failed to write history"))
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite history save failure
|
||||
assert result.generation_succeeded is True
|
||||
assert result.persistence_succeeded is False
|
||||
assert isinstance(result.persistence_error, RuntimeError)
|
||||
assert "Failed to write history" in str(result.persistence_error)
|
||||
|
||||
def test_kv_state_restore_failure_logs_error(self, caplog) -> None:
|
||||
"""P1: Test that KV state restore failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make try_restore_kv_state raise an exception
|
||||
deps["try_restore_kv_state"] = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("KV restore failed"))
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite KV state restore failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_summarization_failure_logs_error(self, caplog) -> None:
|
||||
"""P1: Test that summarization failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make maybe_summarize_history raise an exception
|
||||
deps["maybe_summarize_history"] = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("Summarization failed"))
|
||||
|
||||
request = TurnExecutionRequest(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
runtime_cache="off",
|
||||
model_manager=mgr,
|
||||
dependencies=deps,
|
||||
summarize_old_history=True,
|
||||
)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite summarization failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_kv_state_save_failure_logs_error(self, caplog) -> None:
|
||||
"""P1: Test that KV state save failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make try_save_kv_state raise an exception
|
||||
deps["try_save_kv_state"] = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("KV save failed"))
|
||||
|
||||
request = TurnExecutionRequest(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
runtime_cache="KV_cache",
|
||||
model_manager=mgr,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite KV state save failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_cache_invalidation_on_mismatch_failure_logs_error(self, caplog) -> None:
|
||||
"""P1: Test that cache invalidation failure on mismatch is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make is_state_data_mismatch_error return True to trigger cache invalidation
|
||||
deps["is_state_data_mismatch_error"] = lambda _err: True
|
||||
|
||||
# Make invalidate_cache raise an exception during mismatch handling
|
||||
def failing_invalidate(llm, remove_disk_data=False):
|
||||
raise RuntimeError("Cache invalidation on mismatch failed")
|
||||
|
||||
mgr.invalidate_cache = failing_invalidate
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite cache invalidation failure
|
||||
assert result.generation_succeeded is True
|
||||
|
||||
def test_summary_compaction_failure_logs_error(self, caplog) -> None:
|
||||
"""P1: Test that summary compaction failure is logged."""
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Make maybe_compact_summary raise an exception
|
||||
deps["maybe_compact_summary"] = lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("Summary compaction failed"))
|
||||
|
||||
request = TurnExecutionRequest(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
runtime_cache="off",
|
||||
model_manager=mgr,
|
||||
dependencies=deps,
|
||||
summarize_old_history=True,
|
||||
)
|
||||
|
||||
# Should not raise, but should log the error
|
||||
result = service.execute_turn(request)
|
||||
|
||||
# Generation should still succeed despite summary compaction failure
|
||||
assert result.generation_succeeded is True
|
||||
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import (
|
||||
SessionChatNodeExecutionDependencies,
|
||||
SessionChatNodeExecutionRequest,
|
||||
SessionChatNodeExecutionService,
|
||||
TurnExecutionRequest,
|
||||
TurnExecutionResult,
|
||||
TurnExecutionService,
|
||||
)
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
DummyLogRequest,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
def test_execute_turn_success_updates_history_and_writes_file() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr))
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert result.assistant_text == "assistant reply"
|
||||
assert len(history["turns"]) == 1
|
||||
assert writes and writes[0][0] == "hist.json"
|
||||
|
||||
|
||||
def test_execute_turn_passes_image_max_pixels_to_initial_and_retry_messages() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed_image_max_pixels = []
|
||||
deps["build_chat_messages"] = lambda **kwargs: (
|
||||
observed_image_max_pixels.append(kwargs["image_max_pixels"])
|
||||
or [{"role": "user", "content": "hello"}]
|
||||
)
|
||||
observed_generation = _capture_generation_kwargs(deps)
|
||||
request = replace(_make_request(deps, mgr), image_max_pixels=1048576)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
observed_generation["rebuild_messages_for_turns_limit"](3)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed_image_max_pixels == [1048576, 1048576]
|
||||
|
||||
|
||||
def test_execute_turn_uses_effective_system_prompt_for_initial_retry_and_kv_signature() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed_system_prompts = []
|
||||
observed_kv_prompts = []
|
||||
deps["build_effective_system_prompt"] = lambda **_kwargs: "effort instruction\n\nuser system"
|
||||
deps["build_chat_messages"] = lambda **kwargs: (
|
||||
observed_system_prompts.append(kwargs["system_prompt"])
|
||||
or [{"role": "system", "content": kwargs["system_prompt"]}]
|
||||
)
|
||||
deps["build_kv_state_signature"] = lambda **kwargs: (
|
||||
observed_kv_prompts.append(kwargs["system_prompt"]) or "kvsig"
|
||||
)
|
||||
observed_generation = _capture_generation_kwargs(deps)
|
||||
request = replace(
|
||||
_make_request(deps, mgr),
|
||||
runtime_cache="KV_cache",
|
||||
enable_thinking=True,
|
||||
reasoning_effort="xhigh",
|
||||
)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
observed_generation["rebuild_messages_for_turns_limit"](3)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed_system_prompts == [
|
||||
"effort instruction\n\nuser system",
|
||||
"effort instruction\n\nuser system",
|
||||
]
|
||||
assert observed_kv_prompts == [
|
||||
"effort instruction\n\nuser system",
|
||||
"effort instruction\n\nuser system",
|
||||
]
|
||||
assert history["system_prompt"] == "sys"
|
||||
assert history["turns"][0]["params"]["reasoning_effort"] == "xhigh"
|
||||
|
||||
|
||||
def test_execute_turn_does_not_enable_heartbeat_for_timing_log_level() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed = _capture_generation_kwargs(deps)
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr, log_level="timing"))
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed["heartbeat_logger"] is None
|
||||
|
||||
|
||||
def test_execute_turn_enables_heartbeat_for_debug_log_level() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed = _capture_generation_kwargs(deps)
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr, log_level="debug"))
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert callable(observed["heartbeat_logger"])
|
||||
|
||||
|
||||
def test_execute_turn_disables_heartbeat_for_debug_stream_to_console() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
observed = _capture_generation_kwargs(deps)
|
||||
request = TurnExecutionRequest(
|
||||
**{
|
||||
**_make_request(deps, mgr, log_level="debug").__dict__,
|
||||
"stream_to_console": True,
|
||||
}
|
||||
)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed["heartbeat_logger"] is None
|
||||
|
||||
|
||||
def test_attempt_logger_reports_token_limit_and_exact_completion_tokens(capsys) -> None:
|
||||
logger = GenerationExecutionService()._build_attempt_logger(DummyLogRequest())
|
||||
assert logger is not None
|
||||
|
||||
logger(True, 1, 2.0, 64, 12, None, 10, False)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "max_tokens=" not in out
|
||||
assert "token_limit=64" in out
|
||||
assert "completion_tokens=10" in out
|
||||
assert "tokens_per_second=5.00" in out
|
||||
assert "turns_limit=12" in out
|
||||
|
||||
|
||||
def test_attempt_logger_reports_estimated_completion_tokens(capsys) -> None:
|
||||
logger = GenerationExecutionService()._build_attempt_logger(DummyLogRequest())
|
||||
assert logger is not None
|
||||
|
||||
logger(True, 1, 4.0, 64, 12, None, 10, True)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "completion_tokens_est=10" in out
|
||||
assert "tokens_per_second_est=2.50" in out
|
||||
|
||||
|
||||
def test_execute_turn_passes_advanced_generation_kwargs() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
observed: dict[str, object] = {}
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
generation_result = GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
)
|
||||
deps, _writes = _base_deps(history, run_generation_result=generation_result)
|
||||
deps["run_generation_with_adaptive_retry"] = lambda **kwargs: observed.update(kwargs) or generation_result
|
||||
request = TurnExecutionRequest(
|
||||
**{
|
||||
**_make_request(deps, mgr).__dict__,
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
}
|
||||
|
||||
|
||||
def test_execute_turn_passes_advanced_summary_seed_kwargs() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
observed: dict[str, object] = {}
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
generation_result = GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
)
|
||||
deps, _writes = _base_deps(history, run_generation_result=generation_result)
|
||||
deps["maybe_summarize_history"] = lambda **kwargs: observed.update(kwargs) or history
|
||||
request = TurnExecutionRequest(
|
||||
**{
|
||||
**_make_request(deps, mgr).__dict__,
|
||||
"advanced_summary_generation_kwargs": {"seed": 456},
|
||||
}
|
||||
)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert observed["advanced_generation_kwargs"] == {"seed": 456}
|
||||
|
||||
|
||||
def test_execute_turn_records_advanced_generation_kwargs_in_history_params() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
request = TurnExecutionRequest(
|
||||
**{
|
||||
**_make_request(deps, mgr).__dict__,
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
},
|
||||
"advanced_summary_generation_kwargs": {"seed": 456},
|
||||
"official_sampling_profile": "qwen3.8-non-thinking",
|
||||
}
|
||||
)
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
params = history["turns"][0]["params"]
|
||||
assert params["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
}
|
||||
assert params["advanced_summary_generation_kwargs"] == {"seed": 456}
|
||||
assert params["official_sampling_profile"] == "qwen3.8-non-thinking"
|
||||
|
||||
|
||||
def test_execute_turn_returns_failure_on_non_ctx_error() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="",
|
||||
gen_tokens=32,
|
||||
turns_limit=8,
|
||||
last_err=RuntimeError("backend failed"),
|
||||
succeeded=False,
|
||||
non_ctx_error=True,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr))
|
||||
|
||||
assert result.generation_succeeded is False
|
||||
assert result.assistant_text == ""
|
||||
assert isinstance(result.error, RuntimeError)
|
||||
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import (
|
||||
SessionChatNodeExecutionDependencies,
|
||||
SessionChatNodeExecutionRequest,
|
||||
SessionChatNodeExecutionService,
|
||||
TurnExecutionRequest,
|
||||
TurnExecutionResult,
|
||||
TurnExecutionService,
|
||||
)
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
DummyLogRequest,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
def test_execute_from_node_inputs_matches_execute_turn_path() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.execute_from_node_inputs(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
media=None,
|
||||
max_turns=12,
|
||||
summarize_old_history=True,
|
||||
summary_chunk_turns=3,
|
||||
max_tokens_summary=128,
|
||||
summary_max_chars=1500,
|
||||
dynamic_max_tokens=True,
|
||||
min_generation_tokens=96,
|
||||
safety_margin_tokens=64,
|
||||
persistent_cache="off",
|
||||
repeat_penalty=1.12,
|
||||
repeat_last_n=256,
|
||||
rewrite_continue=True,
|
||||
runtime_cache="off",
|
||||
log_level="timing",
|
||||
suppress_backend_logs=True,
|
||||
history_dir="",
|
||||
reset_session=False,
|
||||
stream_to_console=False,
|
||||
model_manager=mgr,
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
strip_assistant_before_reasoning_filter=False,
|
||||
include_media_and_stream_in_turn_params=True,
|
||||
kv_log_saved_when_not_minimal=False,
|
||||
kv_log_unsupported_when_not_minimal=False,
|
||||
include_error_in_invalidate_message=False,
|
||||
enable_attempt_logging=False,
|
||||
log_prefix="[LLM Session Chat]",
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert result.assistant_text == "assistant reply"
|
||||
assert writes and writes[0][0] == "hist.json"
|
||||
|
||||
|
||||
def test_execute_session_chat_turn_adds_media_stream_params() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.execute_session_chat_turn(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
media=None,
|
||||
max_turns=12,
|
||||
summarize_old_history=True,
|
||||
summary_chunk_turns=3,
|
||||
max_tokens_summary=128,
|
||||
summary_max_chars=1500,
|
||||
dynamic_max_tokens=True,
|
||||
min_generation_tokens=96,
|
||||
safety_margin_tokens=64,
|
||||
persistent_cache="off",
|
||||
repeat_penalty=1.12,
|
||||
repeat_last_n=256,
|
||||
rewrite_continue=True,
|
||||
runtime_cache="off",
|
||||
log_level="timing",
|
||||
suppress_backend_logs=True,
|
||||
history_dir="",
|
||||
reset_session=False,
|
||||
stream_to_console=False,
|
||||
model_manager=mgr,
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
params = history["turns"][0]["params"]
|
||||
assert params["image_used"] is False
|
||||
assert params["image_count"] == 0
|
||||
assert params["audio_used"] is False
|
||||
assert params["audio_format"] == ""
|
||||
assert "streamed" in params
|
||||
user = history["turns"][0]["user"]
|
||||
assert "image_note" in user
|
||||
assert "audio_note" in user
|
||||
assert "media_note" not in user
|
||||
|
||||
|
||||
def test_execute_session_chat_turn_records_image_media_params() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
media = type("ImageMedia", (), {"shape": (3, 16, 16, 3)})()
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "media": media})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
params = history["turns"][0]["params"]
|
||||
assert params["image_used"] is True
|
||||
assert params["image_count"] == 3
|
||||
assert params["audio_used"] is False
|
||||
assert params["audio_format"] == ""
|
||||
|
||||
|
||||
def test_execute_session_chat_turn_records_audio_media_params() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
request = _make_request(deps, mgr)
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "media": {"waveform": object(), "sample_rate": 16000}})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
params = history["turns"][0]["params"]
|
||||
assert params["image_used"] is False
|
||||
assert params["image_count"] == 0
|
||||
assert params["audio_used"] is True
|
||||
assert params["audio_format"] == "wav"
|
||||
|
||||
|
||||
def test_execute_dialogue_cycle_turn_strips_and_omits_media_stream_params() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text=" assistant reply ",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = service.execute_dialogue_cycle_turn(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
media=None,
|
||||
max_turns=12,
|
||||
summarize_old_history=True,
|
||||
summary_chunk_turns=3,
|
||||
max_tokens_summary=128,
|
||||
summary_max_chars=1500,
|
||||
dynamic_max_tokens=True,
|
||||
min_generation_tokens=96,
|
||||
safety_margin_tokens=64,
|
||||
persistent_cache="off",
|
||||
repeat_penalty=1.12,
|
||||
repeat_last_n=256,
|
||||
rewrite_continue=True,
|
||||
runtime_cache="off",
|
||||
log_level="timing",
|
||||
suppress_backend_logs=True,
|
||||
history_dir="",
|
||||
reset_session=False,
|
||||
stream_to_console=False,
|
||||
model_manager=mgr,
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert result.assistant_text == "assistant reply"
|
||||
params = history["turns"][0]["params"]
|
||||
assert "image_used" not in params
|
||||
assert "audio_used" not in params
|
||||
assert "streamed" not in params
|
||||
|
||||
|
||||
def test_execute_session_chat_turn_sets_profile_flags() -> None:
|
||||
service = TurnExecutionService()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _spy_execute_from_node_inputs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "ok"
|
||||
|
||||
service.execute_from_node_inputs = _spy_execute_from_node_inputs # type: ignore[method-assign]
|
||||
result = service.execute_session_chat_turn(user_text="hello")
|
||||
|
||||
assert result == "ok"
|
||||
assert captured["strip_assistant_before_reasoning_filter"] is False
|
||||
assert captured["include_media_and_stream_in_turn_params"] is True
|
||||
assert captured["kv_log_saved_when_not_minimal"] is False
|
||||
assert captured["kv_log_unsupported_when_not_minimal"] is False
|
||||
assert captured["include_error_in_invalidate_message"] is False
|
||||
assert captured["enable_attempt_logging"] is True
|
||||
assert captured["log_prefix"] == "[LLM Session Chat]"
|
||||
|
||||
|
||||
def test_execute_dialogue_cycle_turn_sets_profile_flags() -> None:
|
||||
service = TurnExecutionService()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _spy_execute_from_node_inputs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "ok"
|
||||
|
||||
service.execute_from_node_inputs = _spy_execute_from_node_inputs # type: ignore[method-assign]
|
||||
result = service.execute_dialogue_cycle_turn(user_text="hello")
|
||||
|
||||
assert result == "ok"
|
||||
assert captured["strip_assistant_before_reasoning_filter"] is True
|
||||
assert captured["include_media_and_stream_in_turn_params"] is False
|
||||
assert captured["kv_log_saved_when_not_minimal"] is True
|
||||
assert captured["kv_log_unsupported_when_not_minimal"] is True
|
||||
assert captured["include_error_in_invalidate_message"] is True
|
||||
assert captured["enable_attempt_logging"] is True
|
||||
assert captured["log_prefix"] == "[LLM Dialogue Cycle]"
|
||||
|
||||
|
||||
def test_execute_dialogue_cycle_turn_accepts_log_prefix_override() -> None:
|
||||
service = TurnExecutionService()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _spy_execute_from_node_inputs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return "ok"
|
||||
|
||||
service.execute_from_node_inputs = _spy_execute_from_node_inputs # type: ignore[method-assign]
|
||||
result = service.execute_dialogue_cycle_turn(
|
||||
user_text="hello",
|
||||
log_prefix_override="[LLM Dialogue Cycle A/1]",
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert captured["enable_attempt_logging"] is True
|
||||
assert captured["log_prefix"] == "[LLM Dialogue Cycle A/1]"
|
||||
assert "log_prefix_override" not in captured
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import (
|
||||
SessionChatNodeExecutionDependencies,
|
||||
SessionChatNodeExecutionRequest,
|
||||
SessionChatNodeExecutionService,
|
||||
TurnExecutionRequest,
|
||||
TurnExecutionResult,
|
||||
TurnExecutionService,
|
||||
)
|
||||
from turn_execution_helpers import (
|
||||
DummyManager,
|
||||
DummyLogRequest,
|
||||
_base_deps,
|
||||
_capture_generation_kwargs,
|
||||
_make_request,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
def test_execute_turn_returns_failure_when_model_placeholder() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="unused",
|
||||
gen_tokens=1,
|
||||
turns_limit=1,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
deps["is_no_models_placeholder"] = lambda _model: True
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr))
|
||||
|
||||
assert result.generation_succeeded is False
|
||||
assert result.assistant_text == ""
|
||||
|
||||
|
||||
def test_execute_turn_requires_vision_when_media_is_present() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
request = _make_request(deps, mgr)
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "media": object(), "mmproj": "(Auto-detect)"})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert mgr.last_load_kwargs["vision_required"] is True
|
||||
|
||||
|
||||
def test_execute_turn_validates_media_before_model_load() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
deps["validate_chat_media"] = lambda **_kwargs: (_ for _ in ()).throw(
|
||||
ValueError("AUDIO media is currently supported only for Gemma 4 models.")
|
||||
)
|
||||
request = _make_request(deps, mgr)
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "media": object(), "mmproj": "(Auto-detect)"})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is False
|
||||
assert isinstance(result.error, ValueError)
|
||||
assert "Gemma 4" in str(result.error)
|
||||
assert mgr.loaded is False
|
||||
|
||||
|
||||
def test_execute_turn_wraps_message_build_errors() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
deps["build_chat_messages"] = lambda **_kwargs: (_ for _ in ()).throw(ValueError("bad media shape"))
|
||||
|
||||
result = service.execute_turn(_make_request(deps, mgr))
|
||||
|
||||
assert result.generation_succeeded is False
|
||||
assert isinstance(result.error, ValueError)
|
||||
assert "bad media shape" in str(result.error)
|
||||
|
||||
|
||||
def test_execute_turn_requires_vision_when_mmproj_is_explicit() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
request = _make_request(deps, mgr)
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "mmproj": "mmproj-gemma4.gguf"})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert mgr.last_load_kwargs["vision_required"] is True
|
||||
|
||||
|
||||
def test_execute_turn_does_not_require_vision_for_auto_mmproj_without_media() -> None:
|
||||
service = TurnExecutionService()
|
||||
mgr = DummyManager()
|
||||
history = {"turns": [], "summary": {"enabled": False, "text": ""}, "meta": {}}
|
||||
deps, _writes = _base_deps(
|
||||
history,
|
||||
run_generation_result=GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
),
|
||||
)
|
||||
request = _make_request(deps, mgr)
|
||||
request = TurnExecutionRequest(**{**request.__dict__, "mmproj": "(Auto-detect)"})
|
||||
|
||||
result = service.execute_turn(request)
|
||||
|
||||
assert result.generation_succeeded is True
|
||||
assert mgr.last_load_kwargs["vision_required"] is False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.turn_types import GenerationRunResult
|
||||
from services.generation_execution_service import GenerationExecutionService
|
||||
from services.turn_execution_service import TurnExecutionRequest
|
||||
class DummyManager:
|
||||
def __init__(self) -> None:
|
||||
self.model = object()
|
||||
self.cache_dir_override = None
|
||||
self.loaded = False
|
||||
self.cache_configured = False
|
||||
self.last_load_kwargs = None
|
||||
|
||||
def load_model(self, **kwargs):
|
||||
self.loaded = True
|
||||
self.last_load_kwargs = kwargs
|
||||
self.model = object()
|
||||
return self.model
|
||||
|
||||
def configure_cache(self, *_args, **_kwargs):
|
||||
self.cache_configured = True
|
||||
|
||||
def invalidate_cache(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class DummyLogRequest:
|
||||
enable_attempt_logging = True
|
||||
log_prefix = "[Test]"
|
||||
|
||||
|
||||
def _base_deps(history_ref, *, run_generation_result: GenerationRunResult):
|
||||
writes = []
|
||||
|
||||
deps = {
|
||||
"llama_cpp_available": True,
|
||||
"llama_cpp_import_error": None,
|
||||
"is_no_models_placeholder": lambda _model: False,
|
||||
"get_llm_model_roots": lambda: ["/models"],
|
||||
"resolve_model_and_mmproj": lambda _roots, _model, _mmproj: ("/models/m.gguf", None),
|
||||
"mmproj_auto": "(Auto-detect)",
|
||||
"mmproj_not_required": "(Not required)",
|
||||
"load_history": lambda **_kwargs: (history_ref, "hist.json"),
|
||||
"clear_kv_state_for_session": lambda _sid: None,
|
||||
"rewrite_continue_prompt": lambda **kwargs: type("R", (), {
|
||||
"user_text_for_model": kwargs.get("user_text", ""),
|
||||
"rewritten": False,
|
||||
"detected_language": None,
|
||||
})(),
|
||||
"detect_history_language": lambda _history: "en",
|
||||
"session_cache_root": lambda _sid, _dir: "cache_dir",
|
||||
"validate_chat_media": lambda **_kwargs: None,
|
||||
"build_chat_messages": lambda **_kwargs: [{"role": "user", "content": "hello"}],
|
||||
"build_text_chat_request": lambda **_kwargs: None,
|
||||
"build_kv_state_signature": lambda **_kwargs: "kvsig",
|
||||
"try_restore_kv_state": lambda **_kwargs: None,
|
||||
"is_state_data_mismatch_error": lambda _err: False,
|
||||
"saved_llama_state_size": lambda _state: None,
|
||||
"current_llama_state_size": lambda _llm: None,
|
||||
"kv_state_debug_info": lambda _state: "dbg",
|
||||
"get_context_turns": lambda _history, max_turns=None: [],
|
||||
"mem_kv_state": {},
|
||||
"maybe_compact_summary": lambda **_kwargs: history_ref,
|
||||
"cache_debug_label": lambda _mgr: "cache",
|
||||
"run_generation_with_adaptive_retry": lambda **_kwargs: run_generation_result,
|
||||
"make_suppress_backend_logs": lambda _suppress: __import__("contextlib").nullcontext(),
|
||||
"processing_interrupted": lambda: False,
|
||||
"throw_if_processing_interrupted": lambda: None,
|
||||
"is_interrupt_error": lambda _err: False,
|
||||
"iter_chat_completion_robust": lambda *_args, **_kwargs: iter(()),
|
||||
"create_chat_completion_robust": lambda *_args, **_kwargs: {"choices": [{"message": {"content": "unused"}}]},
|
||||
"extract_stream_content": lambda _chunk: "",
|
||||
"retry_kwargs_with_repeat_last_n_fallback": lambda kwargs, _n: dict(kwargs),
|
||||
"strip_reasoning_output": lambda text: text,
|
||||
"next_turn_id": lambda _history: 1,
|
||||
"now_iso": lambda: "2026-03-21T12:00:00+09:00",
|
||||
"maybe_summarize_history": lambda **_kwargs: history_ref,
|
||||
"atomic_write_json": lambda path, obj: writes.append((path, obj.copy())),
|
||||
"try_save_kv_state": lambda **_kwargs: None,
|
||||
}
|
||||
return deps, writes
|
||||
|
||||
|
||||
def _make_request(deps, mgr: DummyManager, *, log_level: str = "timing") -> TurnExecutionRequest:
|
||||
return TurnExecutionRequest(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
n_ctx=1024,
|
||||
runtime_cache="off",
|
||||
log_level=log_level,
|
||||
model_manager=mgr,
|
||||
dependencies=deps,
|
||||
)
|
||||
|
||||
|
||||
def _capture_generation_kwargs(deps):
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
def run_generation_with_adaptive_retry(**kwargs):
|
||||
observed.update(kwargs)
|
||||
return GenerationRunResult(
|
||||
assistant_text="assistant reply",
|
||||
gen_tokens=64,
|
||||
turns_limit=12,
|
||||
last_err=None,
|
||||
succeeded=True,
|
||||
non_ctx_error=False,
|
||||
)
|
||||
|
||||
deps["run_generation_with_adaptive_retry"] = run_generation_with_adaptive_retry
|
||||
return observed
|
||||
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_config(tmp_path, config):
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(json.dumps({"schema_version": 1, **config}), encoding="utf-8")
|
||||
return str(config_path)
|
||||
|
||||
|
||||
def _patch_llama(module, monkeypatch):
|
||||
calls = []
|
||||
|
||||
class DummyLlama:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "LLAMA_CPP_AVAILABLE", True)
|
||||
monkeypatch.setattr(module, "Llama", DummyLlama)
|
||||
return calls
|
||||
|
||||
|
||||
def _load(module, manager, advanced_backend_kwargs=None):
|
||||
return manager.load_model(
|
||||
model_path="C:/models/test.gguf",
|
||||
mmproj_path=module._MMPROJ_NOT_REQUIRED,
|
||||
n_ctx=4096,
|
||||
n_gpu_layers=0,
|
||||
advanced_backend_kwargs=advanced_backend_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_simple_defaults_omit_backend_kwargs_when_unset(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"n_batch": None, "n_ubatch": None}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {}
|
||||
|
||||
|
||||
def test_simple_defaults_accept_n_batch_and_n_ubatch(load_nodes_module, tmp_path, capsys):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(
|
||||
tmp_path,
|
||||
{"n_ctx": 8192, "advanced_backend_kwargs": {"n_batch": 4096, "n_ubatch": 2048}},
|
||||
)
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {"n_batch": 4096, "n_ubatch": 2048}
|
||||
assert "Warning" not in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["n_batch", "n_ubatch"])
|
||||
def test_simple_defaults_raise_small_backend_batch_to_gemma4_image_token_limit(
|
||||
load_nodes_module, tmp_path, capsys, key
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {key: 128}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {key: 512}
|
||||
assert f"advanced_backend_kwargs.{key}=128 is too small" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["1024", 1024.0, True, [1024]])
|
||||
def test_simple_defaults_ignore_invalid_backend_batch_with_warning(
|
||||
load_nodes_module, tmp_path, capsys, invalid
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"n_batch": invalid, "n_ubatch": 1024}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {"n_ubatch": 1024}
|
||||
assert "Ignoring invalid advanced_backend_kwargs.n_batch" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "limit"),
|
||||
[
|
||||
({"n_ctx": 2048, "advanced_backend_kwargs": {"n_ubatch": 4096}}, 2048),
|
||||
({"n_ctx": 8192, "advanced_backend_kwargs": {"n_batch": 1024, "n_ubatch": 2048}}, 1024),
|
||||
],
|
||||
)
|
||||
def test_simple_defaults_warn_when_backend_will_limit_n_ubatch(
|
||||
load_nodes_module, tmp_path, capsys, config, limit
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(_write_config(tmp_path, config))
|
||||
|
||||
assert defaults["advanced_backend_kwargs"]["n_ubatch"] == config["advanced_backend_kwargs"]["n_ubatch"]
|
||||
assert f"the backend limits it to {limit}" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_simple_defaults_warn_about_unsupported_backend_keys(load_nodes_module, tmp_path, capsys):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"n_batch": 1024, "ctx_checkpoints": 4}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {"n_batch": 1024}
|
||||
assert "Ignoring unsupported advanced_backend_kwargs keys: ctx_checkpoints" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_simple_defaults_keep_backend_kwargs_out_of_generation_kwargs(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(
|
||||
tmp_path,
|
||||
{
|
||||
"advanced_generation_kwargs": {"seed": 7},
|
||||
"advanced_backend_kwargs": {"n_batch": 1024, "n_ubatch": 1024},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert defaults["advanced_generation_kwargs"] == {"seed": 7}
|
||||
|
||||
|
||||
def test_model_manager_passes_backend_kwargs_to_llama(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
|
||||
_load(module, module.GGUFModelManager(), {"n_batch": 2048, "n_ubatch": 1024})
|
||||
|
||||
assert calls[0]["n_batch"] == 2048
|
||||
assert calls[0]["n_ubatch"] == 1024
|
||||
|
||||
|
||||
def test_model_manager_leaves_backend_batch_defaults_when_unset(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
|
||||
_load(module, module.GGUFModelManager())
|
||||
|
||||
assert "n_batch" not in calls[0]
|
||||
assert "n_ubatch" not in calls[0]
|
||||
|
||||
|
||||
def test_model_manager_reloads_when_backend_kwargs_change(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
manager = module.GGUFModelManager()
|
||||
|
||||
first = _load(module, manager, {"n_ubatch": 1024})
|
||||
cached = _load(module, manager, {"n_ubatch": 1024})
|
||||
reloaded = _load(module, manager, {"n_ubatch": 2048})
|
||||
|
||||
assert cached is first
|
||||
assert reloaded is not first
|
||||
assert [call.get("n_ubatch") for call in calls] == [1024, 2048]
|
||||
|
||||
|
||||
def _load_vision(module, monkeypatch, advanced_backend_kwargs=None):
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
|
||||
class DummyHandler:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(module, "chat_handler_factory_map", {"gemma4": object()})
|
||||
monkeypatch.setattr(module, "chat_handler_map", {"gemma4": "Gemma4ChatHandler"})
|
||||
monkeypatch.setattr(module, "chat_handler_class_registry", {"Gemma4ChatHandler": DummyHandler})
|
||||
module.GGUFModelManager().load_model(
|
||||
model_path="C:/models/Gemma-4-test.gguf",
|
||||
mmproj_path="C:/models/mmproj-gemma4.gguf",
|
||||
n_ctx=4096,
|
||||
n_gpu_layers=0,
|
||||
vision_required=True,
|
||||
advanced_backend_kwargs=advanced_backend_kwargs,
|
||||
)
|
||||
return calls[0]
|
||||
|
||||
|
||||
def test_simple_defaults_accept_verbosity_and_logits_all(load_nodes_module, tmp_path, capsys):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"verbosity": 3, "logits_all": False}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {"verbosity": 3, "logits_all": False}
|
||||
assert "Warning" not in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [6, -1, True, "3", 3.0])
|
||||
def test_simple_defaults_ignore_invalid_verbosity_with_warning(load_nodes_module, tmp_path, capsys, invalid):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"verbosity": invalid}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {}
|
||||
assert "Ignoring invalid advanced_backend_kwargs.verbosity" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", [0, 1, "false"])
|
||||
def test_simple_defaults_ignore_invalid_logits_all_with_warning(load_nodes_module, tmp_path, capsys, invalid):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_backend_kwargs": {"logits_all": invalid}})
|
||||
)
|
||||
|
||||
assert defaults["advanced_backend_kwargs"] == {}
|
||||
assert "Ignoring invalid advanced_backend_kwargs.logits_all" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_vision_load_keeps_logits_all_true_when_unset(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
call = _load_vision(module, monkeypatch)
|
||||
|
||||
assert call["logits_all"] is True
|
||||
assert "chat_handler" in call
|
||||
|
||||
|
||||
def test_vision_load_uses_configured_logits_all(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
call = _load_vision(module, monkeypatch, {"logits_all": False})
|
||||
|
||||
assert call["logits_all"] is False
|
||||
|
||||
|
||||
def test_text_only_load_passes_logits_all_only_when_configured(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
|
||||
_load(module, module.GGUFModelManager())
|
||||
_load(module, module.GGUFModelManager(), {"logits_all": True})
|
||||
|
||||
assert "logits_all" not in calls[0]
|
||||
assert calls[1]["logits_all"] is True
|
||||
|
||||
|
||||
def test_model_manager_passes_verbosity_to_llama(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = _patch_llama(module, monkeypatch)
|
||||
|
||||
_load(module, module.GGUFModelManager())
|
||||
_load(module, module.GGUFModelManager(), {"verbosity": 3})
|
||||
|
||||
assert "verbosity" not in calls[0]
|
||||
assert calls[1]["verbosity"] == 3
|
||||
assert calls[1]["verbose"] is False
|
||||
|
||||
|
||||
def test_model_manager_retries_without_verbosity_when_backend_rejects_it(
|
||||
load_nodes_module, monkeypatch, capsys
|
||||
):
|
||||
module = load_nodes_module()
|
||||
calls = []
|
||||
|
||||
class OldLlama:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
if "verbosity" in kwargs:
|
||||
raise TypeError("Llama.__init__() got an unexpected keyword argument 'verbosity'")
|
||||
|
||||
monkeypatch.setattr(module, "LLAMA_CPP_AVAILABLE", True)
|
||||
monkeypatch.setattr(module, "Llama", OldLlama)
|
||||
|
||||
_load(module, module.GGUFModelManager(), {"verbosity": 3, "n_ubatch": 1024})
|
||||
|
||||
assert len(calls) == 2
|
||||
assert "verbosity" not in calls[1]
|
||||
assert calls[1]["n_ubatch"] == 1024
|
||||
assert "rejected 'verbosity'" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_model_manager_does_not_swallow_unrelated_type_error(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class BrokenLlama:
|
||||
def __init__(self, **kwargs):
|
||||
raise TypeError("unsupported operand type(s)")
|
||||
|
||||
monkeypatch.setattr(module, "LLAMA_CPP_AVAILABLE", True)
|
||||
monkeypatch.setattr(module, "Llama", BrokenLlama)
|
||||
|
||||
with pytest.raises(TypeError, match="unsupported operand"):
|
||||
_load(module, module.GGUFModelManager(), {"verbosity": 3})
|
||||
|
||||
|
||||
def test_session_chat_simple_forwards_backend_kwargs(load_nodes_module, tmp_path, monkeypatch):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
observed = {}
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_run_session_chat_from_inputs",
|
||||
lambda **kwargs: observed.update(kwargs) or ("ok",),
|
||||
)
|
||||
|
||||
module.LLMSessionChatSimpleNode().chat_stream(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="dummy.gguf",
|
||||
mmproj="(Auto-detect)",
|
||||
history_dir="",
|
||||
config_path=_write_config(tmp_path, {"advanced_backend_kwargs": {"n_ubatch": 1024}}),
|
||||
)
|
||||
|
||||
assert observed["advanced_backend_kwargs"] == {"n_ubatch": 1024}
|
||||
|
||||
|
||||
def test_session_chat_turn_kwargs_carry_backend_kwargs_to_turn_execution(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
defaults = module._load_simple_defaults("")
|
||||
chat_kwargs = module._build_session_chat_simple_chat_kwargs(
|
||||
defaults=defaults,
|
||||
model="dummy.gguf",
|
||||
history_dir="",
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
)
|
||||
chat_kwargs["advanced_backend_kwargs"] = {"n_ubatch": 1024}
|
||||
|
||||
node_request = module._build_session_chat_node_execution_request(
|
||||
user_text="hello",
|
||||
session_id="sid",
|
||||
model="dummy.gguf",
|
||||
mmproj="(Auto-detect)",
|
||||
media=None,
|
||||
enable_thinking=False,
|
||||
official_sampling_profile="",
|
||||
**chat_kwargs,
|
||||
)
|
||||
|
||||
assert node_request.turn_kwargs["advanced_backend_kwargs"] == {"n_ubatch": 1024}
|
||||
|
||||
|
||||
def test_dialogue_cycle_simple_applies_backend_kwargs_to_both_models(
|
||||
load_nodes_module, tmp_path, monkeypatch
|
||||
):
|
||||
module = load_nodes_module(available_models=["a.gguf", "b.gguf"])
|
||||
observed = []
|
||||
|
||||
def execute_dialogue_cycle_turn(**kwargs):
|
||||
observed.append((kwargs["model"], kwargs["advanced_backend_kwargs"]))
|
||||
return module.TurnExecutionResult(assistant_text="reply", generation_succeeded=True)
|
||||
|
||||
monkeypatch.setattr(module, "LLAMA_CPP_AVAILABLE", True)
|
||||
monkeypatch.setattr(module, "_execute_dialogue_cycle_turn", execute_dialogue_cycle_turn)
|
||||
|
||||
module.LLMDialogueCycleSimpleNode().chat_cycle_simple(
|
||||
initial_user_text="hello",
|
||||
system="",
|
||||
systemA="",
|
||||
systemB="",
|
||||
session_id="sid",
|
||||
cycles=1,
|
||||
modelA="a.gguf",
|
||||
modelB="b.gguf",
|
||||
history_dir=str(tmp_path),
|
||||
config_path=_write_config(tmp_path, {"advanced_backend_kwargs": {"n_ubatch": 1024}}),
|
||||
)
|
||||
|
||||
assert observed == [("a.gguf", {"n_ubatch": 1024}), ("b.gguf", {"n_ubatch": 1024})]
|
||||
@@ -1,25 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
|
||||
def test_list_gguf_recursive_handles_extension_and_mmproj_case(monkeypatch, tmp_path: Path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_list_gguf_recursive_handles_extension_and_mmproj_case(load_nodes_module, tmp_path: Path):
|
||||
module = load_nodes_module()
|
||||
|
||||
(tmp_path / "ModelA.GGUF").write_text("x", encoding="utf-8")
|
||||
(tmp_path / "MMPROJ-ModelA.GGUF").write_text("x", encoding="utf-8")
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
from core.defaults import LOG_LEVEL_OPTIONS, PERSISTENT_CACHE_OPTIONS, RUNTIME_CACHE_OPTIONS
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
module = importlib.import_module("llm_session_nodes")
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_get_available_models_and_mmprojs",
|
||||
lambda: (["dummy.gguf"], [module._MMPROJ_AUTO, module._MMPROJ_NOT_REQUIRED]),
|
||||
)
|
||||
return module
|
||||
|
||||
|
||||
def test_session_chat_input_types_default_values(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_session_chat_input_types_default_values(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
input_types = module._input_types_session_chat()
|
||||
required = input_types["required"]
|
||||
optional = input_types["optional"]
|
||||
|
||||
assert "media" in optional
|
||||
assert "image" not in optional
|
||||
assert optional["media"][0] == "*"
|
||||
assert required["system_prompt"][1]["default"] == "You are a helpful assistant."
|
||||
assert optional["max_turns"][1]["default"] == 12
|
||||
assert optional["summary_chunk_turns"][1]["default"] == 3
|
||||
@@ -44,10 +26,12 @@ def test_session_chat_input_types_default_values(monkeypatch):
|
||||
assert optional["persistent_cache"][0] == list(PERSISTENT_CACHE_OPTIONS)
|
||||
assert optional["runtime_cache"][0] == list(RUNTIME_CACHE_OPTIONS)
|
||||
assert optional["log_level"][0] == list(LOG_LEVEL_OPTIONS)
|
||||
assert "reasoning_effort" not in required
|
||||
assert "reasoning_effort" not in optional
|
||||
|
||||
|
||||
def test_dialogue_cycle_input_types_default_values(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_dialogue_cycle_input_types_default_values(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
input_types = module._input_types_dialogue_cycle()
|
||||
required = input_types["required"]
|
||||
@@ -65,10 +49,23 @@ def test_dialogue_cycle_input_types_default_values(monkeypatch):
|
||||
assert optional["persistent_cache"][0] == list(PERSISTENT_CACHE_OPTIONS)
|
||||
assert optional["runtime_cache"][0] == list(RUNTIME_CACHE_OPTIONS)
|
||||
assert optional["log_level"][0] == list(LOG_LEVEL_OPTIONS)
|
||||
assert "reasoning_effort" not in required
|
||||
assert "reasoning_effort" not in optional
|
||||
|
||||
|
||||
def test_enable_thinking_overrides_supported_chat_formats(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_session_chat_simple_uses_media_input(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
input_types = module._input_types_session_chat_simple()
|
||||
optional = input_types["optional"]
|
||||
|
||||
assert "media" in optional
|
||||
assert "image" not in optional
|
||||
assert optional["media"][0] == "*"
|
||||
|
||||
|
||||
def test_enable_thinking_overrides_supported_chat_formats(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
chat_handler_overrides = module._merge_enable_thinking_chat_handler_overrides(
|
||||
{"qwen3.5": {"image_min_tokens": 2048}},
|
||||
@@ -86,8 +83,8 @@ def test_enable_thinking_overrides_supported_chat_formats(monkeypatch):
|
||||
assert text_chat_builder_overrides["gemma4"]["enable_thinking"] is True
|
||||
|
||||
|
||||
def test_model_specific_config_override_wins_over_full_node_default(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_specific_config_override_wins_over_full_node_default(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
chat_handler_overrides = module._merge_enable_thinking_chat_handler_overrides(
|
||||
{"gemma4": {"enable_thinking": True}},
|
||||
@@ -102,8 +99,8 @@ def test_model_specific_config_override_wins_over_full_node_default(monkeypatch)
|
||||
assert text_chat_builder_overrides["gemma4"]["enable_thinking"] is True
|
||||
|
||||
|
||||
def test_simple_defaults_enable_thinking_overrides_supported_chat_formats(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_enable_thinking_overrides_supported_chat_formats(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
@@ -127,14 +124,292 @@ def test_simple_defaults_enable_thinking_overrides_supported_chat_formats(monkey
|
||||
assert defaults["text_chat_builder_overrides"]["gemma4"]["enable_thinking"] is True
|
||||
|
||||
|
||||
def test_simple_defaults_reads_only_advanced_seed_kwargs(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_reads_qwen38_reasoning_effort_without_qwen35_fallback(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"advanced_generation_kwargs": {"seed": "123", "top_k": 40},
|
||||
"qwen3.5": {"enable_thinking": True, "reasoning_effort": "low"},
|
||||
"qwen3.8": {"enable_thinking": True, "reasoning_effort": "xhigh"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["reasoning_effort"] == "xhigh"
|
||||
assert defaults["chat_handler_overrides"]["qwen3.8"]["enable_thinking"] is True
|
||||
assert defaults["text_chat_builder_overrides"]["qwen3.8"]["enable_thinking"] is True
|
||||
|
||||
|
||||
def test_simple_defaults_invalid_qwen38_reasoning_effort_warns_and_uses_medium(
|
||||
load_nodes_module, tmp_path, capsys
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"schema_version": 1, "qwen3.8": {"reasoning_effort": "maximum"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["reasoning_effort"] == "medium"
|
||||
assert "Invalid qwen3.8.reasoning_effort" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_simple_defaults_does_not_use_qwen35_reasoning_effort_for_qwen38(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"schema_version": 1, "qwen3.5": {"reasoning_effort": "xhigh"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["reasoning_effort"] == "medium"
|
||||
|
||||
|
||||
def test_simple_defaults_reads_model_specific_official_sampling_overrides(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"qwen3.8": {"official_sampling_override": True},
|
||||
"gemma4": {"official_sampling_override": "true"},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["official_sampling_overrides"] == {
|
||||
"qwen3.8": True,
|
||||
"gemma4": True,
|
||||
}
|
||||
|
||||
|
||||
def test_simple_defaults_disables_official_sampling_overrides_by_default(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(json.dumps({"schema_version": 1}), encoding="utf-8")
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["official_sampling_overrides"] == {
|
||||
"qwen3.8": False,
|
||||
"gemma4": False,
|
||||
}
|
||||
|
||||
|
||||
def test_official_sampling_override_uses_qwen38_thinking_mode(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
base_kwargs = {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.4,
|
||||
"repeat_penalty": 1.4,
|
||||
"enable_thinking": False,
|
||||
"chat_handler_overrides": {"qwen3.8": {"enable_thinking": True}},
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 123,
|
||||
"top_k": 40,
|
||||
"min_p": 0.3,
|
||||
"present_penalty": 0.4,
|
||||
},
|
||||
}
|
||||
|
||||
resolved = module._resolve_official_sampling_turn_kwargs(
|
||||
model="Qwen3.8-27B.gguf",
|
||||
turn_kwargs=base_kwargs,
|
||||
official_sampling_overrides={"qwen3.8": True},
|
||||
)
|
||||
|
||||
assert resolved["official_sampling_profile"] == "qwen3.8-thinking"
|
||||
assert resolved["temperature"] == 1.0
|
||||
assert resolved["top_p"] == 0.95
|
||||
assert resolved["repeat_penalty"] == 1.0
|
||||
assert resolved["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 0.0,
|
||||
}
|
||||
assert base_kwargs["temperature"] == 0.2
|
||||
|
||||
|
||||
def test_disabled_official_sampling_override_preserves_manual_values(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
base_kwargs = {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.4,
|
||||
"repeat_penalty": 1.4,
|
||||
"advanced_generation_kwargs": {"top_k": 40, "min_p": 0.3},
|
||||
}
|
||||
|
||||
resolved = module._resolve_official_sampling_turn_kwargs(
|
||||
model="Qwen3.8-27B.gguf",
|
||||
turn_kwargs=base_kwargs,
|
||||
official_sampling_overrides={"qwen3.8": False},
|
||||
)
|
||||
|
||||
assert resolved == base_kwargs
|
||||
assert "official_sampling_profile" not in resolved
|
||||
|
||||
|
||||
def test_session_chat_simple_applies_official_sampling_after_model_selection(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["Qwen3.8-27B.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.4,
|
||||
"repeat_penalty": 1.4,
|
||||
"qwen3.8": {
|
||||
"enable_thinking": True,
|
||||
"official_sampling_override": True,
|
||||
},
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 123,
|
||||
"top_k": 40,
|
||||
"min_p": 0.3,
|
||||
"present_penalty": 0.4,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
chat_kwargs = module._build_session_chat_simple_chat_kwargs(
|
||||
defaults=defaults,
|
||||
model="Qwen3.8-27B.gguf",
|
||||
history_dir="",
|
||||
chat_handler_overrides=defaults["chat_handler_overrides"],
|
||||
text_chat_builder_overrides=defaults["text_chat_builder_overrides"],
|
||||
)
|
||||
|
||||
assert chat_kwargs["official_sampling_profile"] == "qwen3.8-thinking"
|
||||
assert chat_kwargs["temperature"] == 1.0
|
||||
assert chat_kwargs["top_p"] == 0.95
|
||||
assert chat_kwargs["repeat_penalty"] == 1.0
|
||||
assert chat_kwargs["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_official_sampling_override_uses_qwen38_non_thinking_mode(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
resolved = module._resolve_official_sampling_turn_kwargs(
|
||||
model="Qwen3.8-27B.gguf",
|
||||
turn_kwargs={
|
||||
"temperature": 1.2,
|
||||
"top_p": 0.5,
|
||||
"repeat_penalty": 1.3,
|
||||
"enable_thinking": False,
|
||||
"advanced_generation_kwargs": {},
|
||||
},
|
||||
official_sampling_overrides={"qwen3.8": True},
|
||||
)
|
||||
|
||||
assert resolved["official_sampling_profile"] == "qwen3.8-non-thinking"
|
||||
assert resolved["temperature"] == 0.7
|
||||
assert resolved["top_p"] == 0.8
|
||||
assert resolved["repeat_penalty"] == 1.0
|
||||
assert resolved["advanced_generation_kwargs"] == {
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"present_penalty": 1.5,
|
||||
}
|
||||
|
||||
|
||||
def test_dialogue_cycle_resolves_official_sampling_per_model(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
common_turn_kwargs = {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.4,
|
||||
"repeat_penalty": 1.4,
|
||||
"enable_thinking": False,
|
||||
"chat_handler_overrides": {"qwen3.8": {"enable_thinking": True}},
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": 123,
|
||||
"top_k": 40,
|
||||
"min_p": 0.3,
|
||||
"present_penalty": 0.4,
|
||||
},
|
||||
"official_sampling_overrides": {"qwen3.8": True, "gemma4": True},
|
||||
}
|
||||
|
||||
request = module._build_dialogue_cycle_request(
|
||||
initial_user_text="hello",
|
||||
session_id="sampling",
|
||||
cycles=1,
|
||||
system_prompt="",
|
||||
system_prompt_A="",
|
||||
system_prompt_B="",
|
||||
runtime_cache="off",
|
||||
stream_to_console=False,
|
||||
reset_session=True,
|
||||
history_dir="",
|
||||
common_turn_kwargs=common_turn_kwargs,
|
||||
model_a="Qwen3.8-27B.gguf",
|
||||
mmproj_a="None",
|
||||
model_b="gemma-4-31b.gguf",
|
||||
mmproj_b="None",
|
||||
)
|
||||
|
||||
assert request.turn_kwargs_A["official_sampling_profile"] == "qwen3.8-thinking"
|
||||
assert request.turn_kwargs_A["temperature"] == 1.0
|
||||
assert request.turn_kwargs_A["advanced_generation_kwargs"]["top_k"] == 20
|
||||
assert request.turn_kwargs_B["official_sampling_profile"] == "gemma4"
|
||||
assert request.turn_kwargs_B["temperature"] == 1.0
|
||||
assert request.turn_kwargs_B["top_p"] == 0.95
|
||||
assert request.turn_kwargs_B["repeat_penalty"] == 1.4
|
||||
assert request.turn_kwargs_B["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 64,
|
||||
"min_p": 0.3,
|
||||
"present_penalty": 0.4,
|
||||
}
|
||||
assert "official_sampling_overrides" not in request.turn_kwargs_A
|
||||
assert "official_sampling_overrides" not in request.turn_kwargs_B
|
||||
|
||||
|
||||
def test_simple_defaults_reads_supported_advanced_generation_kwargs(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"advanced_generation_kwargs": {
|
||||
"seed": "123",
|
||||
"top_k": 40,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
},
|
||||
"advanced_summary_generation_kwargs": {"seed": 456, "temperature": 0.0},
|
||||
}
|
||||
),
|
||||
@@ -143,19 +418,72 @@ def test_simple_defaults_reads_only_advanced_seed_kwargs(monkeypatch, tmp_path):
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["advanced_generation_kwargs"] == {"seed": 123}
|
||||
assert defaults["advanced_generation_kwargs"] == {
|
||||
"seed": 123,
|
||||
"top_k": 40,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
}
|
||||
assert defaults["advanced_summary_generation_kwargs"] == {"seed": 456}
|
||||
|
||||
|
||||
def test_simple_defaults_warns_for_unsupported_advanced_keys(monkeypatch, tmp_path, capsys):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_does_not_inject_advanced_generation_backend_defaults(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(json.dumps({"schema_version": 1}), encoding="utf-8")
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["advanced_generation_kwargs"] == {}
|
||||
|
||||
|
||||
def test_dialogue_cycle_simple_forwards_shared_advanced_generation_kwargs(
|
||||
load_nodes_module, tmp_path
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"advanced_generation_kwargs": {
|
||||
"top_k": 64,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
chat_kwargs = module._build_dialogue_cycle_simple_chat_kwargs(
|
||||
defaults=defaults,
|
||||
force_text_only=False,
|
||||
history_dir="",
|
||||
reset_session=False,
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
)
|
||||
|
||||
assert chat_kwargs["advanced_generation_kwargs"] == {
|
||||
"top_k": 64,
|
||||
"min_p": 0.05,
|
||||
"present_penalty": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_simple_defaults_warns_for_unsupported_advanced_keys(load_nodes_module, tmp_path, capsys):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"log_level": "timing",
|
||||
"advanced_generation_kwargs": {"seed": 123, "top_k": 40, "min_p": 0.05},
|
||||
"advanced_generation_kwargs": {"seed": 123, "top_k": 40, "typical_p": 0.95},
|
||||
"advanced_summary_generation_kwargs": {"seed": 456, "temperature": 0.0},
|
||||
}
|
||||
),
|
||||
@@ -165,19 +493,21 @@ def test_simple_defaults_warns_for_unsupported_advanced_keys(monkeypatch, tmp_pa
|
||||
module._load_simple_defaults(str(config_path))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Warning: Ignoring unsupported advanced_generation_kwargs keys: min_p, top_k" in out
|
||||
assert "Warning: Ignoring unsupported advanced_generation_kwargs keys: typical_p" in out
|
||||
assert "Warning: Ignoring unsupported advanced_summary_generation_kwargs keys: temperature" in out
|
||||
|
||||
|
||||
def test_simple_defaults_suppresses_unsupported_advanced_key_warning_in_minimal_log(monkeypatch, tmp_path, capsys):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_suppresses_unsupported_advanced_key_warning_in_minimal_log(
|
||||
load_nodes_module, tmp_path, capsys
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"log_level": "minimal",
|
||||
"advanced_generation_kwargs": {"seed": 123, "top_k": 40},
|
||||
"advanced_generation_kwargs": {"seed": 123, "typical_p": 0.95},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -188,8 +518,36 @@ def test_simple_defaults_suppresses_unsupported_advanced_key_warning_in_minimal_
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_simple_defaults_omits_invalid_advanced_seed_kwargs(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_omits_invalid_advanced_sampling_kwargs(
|
||||
load_nodes_module, tmp_path, capsys
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"advanced_generation_kwargs": {
|
||||
"top_k": 20.5,
|
||||
"min_p": 1.1,
|
||||
"present_penalty": -0.1,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
defaults = module._load_simple_defaults(str(config_path))
|
||||
|
||||
assert defaults["advanced_generation_kwargs"] == {}
|
||||
out = capsys.readouterr().out
|
||||
assert "advanced_generation_kwargs.top_k" in out
|
||||
assert "advanced_generation_kwargs.min_p" in out
|
||||
assert "advanced_generation_kwargs.present_penalty" in out
|
||||
|
||||
|
||||
def test_simple_defaults_omits_invalid_advanced_seed_kwargs(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeTensor:
|
||||
def __init__(self, array):
|
||||
self._array = np.asarray(array, dtype=np.float32)
|
||||
self.shape = self._array.shape
|
||||
|
||||
def size(self, dim):
|
||||
return self._array.shape[dim]
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
return self._array
|
||||
|
||||
def __getitem__(self, index):
|
||||
return FakeTensor(self._array[index])
|
||||
|
||||
|
||||
def test_build_chat_messages_accepts_image_batch_media(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
image_batch = FakeTensor(np.zeros((2, 2, 2, 3), dtype=np.float32))
|
||||
|
||||
messages = module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="describe",
|
||||
media=image_batch,
|
||||
model_path="C:/models/gemma-4-12B-it.gguf",
|
||||
)
|
||||
|
||||
content = messages[-1]["content"]
|
||||
image_parts = [part for part in content if part["type"] == "image_url"]
|
||||
assert len(image_parts) == 2
|
||||
assert content[-1] == {"type": "text", "text": "describe"}
|
||||
|
||||
|
||||
def test_build_chat_messages_accepts_gemma4_audio_media(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
audio = {"waveform": np.zeros((1, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
messages = module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="transcribe",
|
||||
media=audio,
|
||||
model_path="C:/models/google-gemma-4-12B.gguf",
|
||||
)
|
||||
|
||||
content = messages[-1]["content"]
|
||||
assert content[0] == {"type": "text", "text": "transcribe"}
|
||||
audio_part = content[1]
|
||||
assert audio_part["type"] == "input_audio"
|
||||
assert audio_part["input_audio"]["format"] == "wav"
|
||||
assert base64.b64decode(audio_part["input_audio"]["data"]).startswith(b"RIFF")
|
||||
|
||||
|
||||
def test_build_chat_messages_accepts_multiple_autogrow_media_inputs(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
first_image = FakeTensor(np.zeros((1, 2, 2, 3), dtype=np.float32))
|
||||
second_image_batch = FakeTensor(np.zeros((2, 2, 2, 3), dtype=np.float32))
|
||||
|
||||
messages = module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="compare",
|
||||
media={"media_1": second_image_batch, "media_0": first_image},
|
||||
model_path="C:/models/qwen3-vl.gguf",
|
||||
)
|
||||
|
||||
content = messages[-1]["content"]
|
||||
assert [part["type"] for part in content] == [
|
||||
"image_url",
|
||||
"image_url",
|
||||
"image_url",
|
||||
"text",
|
||||
]
|
||||
assert content[-1] == {"type": "text", "text": "compare"}
|
||||
|
||||
|
||||
def test_build_chat_messages_preserves_mixed_media_connector_order(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
image = FakeTensor(np.zeros((1, 2, 2, 3), dtype=np.float32))
|
||||
audio = {"waveform": np.zeros((1, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
messages = module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="compare",
|
||||
media=(image, audio),
|
||||
model_path="C:/models/gemma-4-12B-it.gguf",
|
||||
)
|
||||
|
||||
content = messages[-1]["content"]
|
||||
assert [part["type"] for part in content] == ["text", "image_url", "input_audio"]
|
||||
|
||||
|
||||
def test_build_chat_messages_rejects_audio_for_non_gemma4(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
audio = {"waveform": np.zeros((1, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
with pytest.raises(ValueError, match="Gemma 4"):
|
||||
module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="transcribe",
|
||||
media=audio,
|
||||
model_path="C:/models/qwen3-vl.gguf",
|
||||
)
|
||||
|
||||
|
||||
def test_validate_chat_media_rejects_audio_for_non_gemma4_before_build(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
audio = {"waveform": np.zeros((1, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
with pytest.raises(ValueError, match="Gemma 4"):
|
||||
module.validate_chat_media(media=audio, model_path="C:/models/qwen3-vl.gguf")
|
||||
|
||||
|
||||
def test_validate_chat_media_rejects_invalid_audio_shape(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
audio = {"waveform": np.zeros((2, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
with pytest.raises(ValueError, match="batches are not supported"):
|
||||
module.validate_chat_media(media=audio, model_path="C:/models/gemma-4-12B.gguf")
|
||||
|
||||
|
||||
def test_validate_chat_media_identifies_invalid_autogrow_connector(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
image = FakeTensor(np.zeros((1, 2, 2, 3), dtype=np.float32))
|
||||
audio = {"waveform": np.zeros((1, 1, 160), dtype=np.float32), "sample_rate": 16000}
|
||||
|
||||
with pytest.raises(ValueError, match=r"media_1:.*Gemma 4"):
|
||||
module.validate_chat_media(media=(image, audio), model_path="C:/models/qwen3-vl.gguf")
|
||||
|
||||
|
||||
def test_build_chat_messages_rejects_unsupported_media(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported media input"):
|
||||
module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="hello",
|
||||
media=object(),
|
||||
model_path="C:/models/gemma-4-12B-it.gguf",
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_image_media_shim_prefers_media_when_present(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
media = object()
|
||||
image = object()
|
||||
|
||||
assert module._resolve_legacy_image_media(media, image) is media
|
||||
assert module._resolve_legacy_image_media(None, image) is image
|
||||
|
||||
|
||||
def test_resolve_session_chat_media_prefers_autogrow_then_legacy(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
first = object()
|
||||
second = object()
|
||||
legacy = object()
|
||||
|
||||
resolved = module._resolve_session_chat_media(
|
||||
media_inputs={"media_1": second, "media_0": first},
|
||||
media=legacy,
|
||||
)
|
||||
|
||||
assert resolved == (first, second)
|
||||
assert module._resolve_session_chat_media(media_inputs={}, media=legacy) is legacy
|
||||
|
||||
|
||||
def test_session_chat_methods_still_accept_legacy_image_kwarg(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
full_signature = inspect.signature(module.LLMSessionChatNode.chat_stream)
|
||||
simple_signature = inspect.signature(module.LLMSessionChatSimpleNode.chat_stream)
|
||||
|
||||
assert "media" in full_signature.parameters
|
||||
assert "image" in full_signature.parameters
|
||||
assert "media" in simple_signature.parameters
|
||||
assert "image" in simple_signature.parameters
|
||||
@@ -1,46 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
@pytest.mark.parametrize(
|
||||
("model_path", "expected_family"),
|
||||
[
|
||||
("C:/models/LLM/Gemma-4-Vision-Instruct.gguf", "gemma4"),
|
||||
("C:/models/LLM/Gemma4-Vision-Instruct.gguf", "gemma4"),
|
||||
("C:/models/LLM/LFM2.5-VL-Instruct.gguf", "lfm2.5-vl"),
|
||||
("C:/models/LLM/LFM2_5VL-Instruct.gguf", "lfm2.5-vl"),
|
||||
("C:/models/LLM/MiniCPM-V-4.6.gguf", "minicpm-v-4.6"),
|
||||
("C:/models/LLM/MiniCPM-V-4_6.gguf", "minicpm-v-4.6"),
|
||||
("C:/models/LLM/MiniCPMV46.gguf", "minicpm-v-4.6"),
|
||||
("C:/models/LLM/Qwen-3.5-27B-Q4_K_M.gguf", "qwen3.5"),
|
||||
("C:/models/LLM/Qwen-3_5-27B-Q4_K_M.gguf", "qwen3.5"),
|
||||
("C:/models/LLM/Qwen-3.6-27B-Q4_K_M.gguf", "qwen3.5"),
|
||||
("C:/models/LLM/Qwen-3_6-27B-Q4_K_M.gguf", "qwen3.5"),
|
||||
("C:/models/LLM/Qwen3.8-27B-Q4_K_M.gguf", "qwen3.8"),
|
||||
("C:/models/LLM/Qwen3_8-27B-Q4_K_M.gguf", "qwen3.8"),
|
||||
("C:/models/LLM/Qwen38-27B-Q4_K_M.gguf", "qwen3.8"),
|
||||
("C:/models/LLM/Qwen-3.8-27B-Q4_K_M.gguf", "qwen3.8"),
|
||||
("C:/models/LLM/Qwen-3_8-27B-Q4_K_M.gguf", "qwen3.8"),
|
||||
],
|
||||
)
|
||||
def test_detect_model_family_aliases(load_nodes_module, model_path, expected_family):
|
||||
module = load_nodes_module()
|
||||
|
||||
assert module._detect_model_family(model_path) == expected_family
|
||||
|
||||
|
||||
def test_detect_model_family_gemma4_aliases(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
@pytest.mark.parametrize(
|
||||
("model_path", "expected_variant"),
|
||||
[
|
||||
("C:/models/Gemma-4-E2B-it.gguf", "e2b"),
|
||||
("C:/models/Gemma4-E4B-it.gguf", "e4b"),
|
||||
("C:/models/Gemma-4-26B-A4B-it.gguf", "26ba4b"),
|
||||
("C:/models/Gemma-4-31B-it.gguf", "31b"),
|
||||
("C:/models/Gemma-4-unknown.gguf", None),
|
||||
],
|
||||
)
|
||||
def test_detect_gemma4_variant(load_nodes_module, model_path, expected_variant):
|
||||
module = load_nodes_module()
|
||||
|
||||
family_dash = module._detect_model_family("C:/models/LLM/Gemma-4-Vision-Instruct.gguf")
|
||||
family_nodash = module._detect_model_family("C:/models/LLM/Gemma4-Vision-Instruct.gguf")
|
||||
|
||||
assert family_dash == "gemma4"
|
||||
assert family_nodash == "gemma4"
|
||||
assert module._detect_gemma4_variant(model_path) == expected_variant
|
||||
|
||||
|
||||
def test_detect_gemma4_variant(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_minicpm_v46_declares_handler_and_generation_defaults(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
assert module._detect_gemma4_variant("C:/models/Gemma-4-E2B-it.gguf") == "e2b"
|
||||
assert module._detect_gemma4_variant("C:/models/Gemma4-E4B-it.gguf") == "e4b"
|
||||
assert module._detect_gemma4_variant("C:/models/Gemma-4-26B-A4B-it.gguf") == "26ba4b"
|
||||
assert module._detect_gemma4_variant("C:/models/Gemma-4-31B-it.gguf") == "31b"
|
||||
assert module._detect_gemma4_variant("C:/models/Gemma-4-unknown.gguf") is None
|
||||
assert module.DECLARED_CHAT_HANDLER_MAP["minicpm-v-4.6"] == "MiniCPMV46ChatHandler"
|
||||
assert module.CHAT_HANDLER_KWARGS_MAP["minicpm-v-4.6"]["enable_thinking"] is False
|
||||
|
||||
|
||||
def test_gemma4_e2b_false_vision_warning(monkeypatch, capsys):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_qwen38_reuses_qwen35_handler_and_generation_defaults(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
assert module._detect_model_family("C:/models/Qwen3.8-27B.gguf") == "qwen3.8"
|
||||
assert module.DECLARED_CHAT_HANDLER_MAP["qwen3.8"] == "Qwen35ChatHandler"
|
||||
assert module.CHAT_HANDLER_KWARGS_MAP["qwen3.8"] == {
|
||||
"enable_thinking": False,
|
||||
"image_min_tokens": 1024,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "mmproj_name"),
|
||||
[
|
||||
("Qwen-3.5-27B-Q4_K_M.gguf", "mmproj-Qwen-3.5-27B-f16.gguf"),
|
||||
("Qwen-3.6-27B-Q4_K_M.gguf", "mmproj-Qwen-3_6-27B-f16.gguf"),
|
||||
("Qwen3.8-27B-Q4_K_M.gguf", "mmproj-Qwen3.8-27B-f16.gguf"),
|
||||
],
|
||||
)
|
||||
def test_qwen3x_mmproj_auto_detection_uses_family_aliases(
|
||||
load_nodes_module,
|
||||
tmp_path,
|
||||
model_name,
|
||||
mmproj_name,
|
||||
):
|
||||
module = load_nodes_module()
|
||||
model_path = tmp_path / model_name
|
||||
mmproj_path = tmp_path / mmproj_name
|
||||
model_path.write_text("model", encoding="utf-8")
|
||||
mmproj_path.write_text("projector", encoding="utf-8")
|
||||
|
||||
manager = module.GGUFModelManager()
|
||||
family = module._detect_model_family(str(model_path))
|
||||
detected = manager._auto_detect_mmproj(str(model_path), family)
|
||||
|
||||
assert detected == manager._normalize_path(str(mmproj_path))
|
||||
|
||||
|
||||
|
||||
def test_gemma4_e2b_false_vision_warning(load_nodes_module, monkeypatch, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
module._warn_if_gemma4_vision_thinking_required(
|
||||
"C:/models/Gemma-4-E2B-it.gguf",
|
||||
@@ -53,8 +107,8 @@ def test_gemma4_e2b_false_vision_warning(monkeypatch, capsys):
|
||||
assert "enable_thinking=True" in captured.out
|
||||
|
||||
|
||||
def test_gemma4_toggle_capable_variants_do_not_warn(monkeypatch, capsys):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_gemma4_toggle_capable_variants_do_not_warn(load_nodes_module, monkeypatch, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
module._warn_if_gemma4_vision_thinking_required(
|
||||
"C:/models/Gemma-4-31B-it.gguf",
|
||||
@@ -76,28 +130,203 @@ def test_gemma4_toggle_capable_variants_do_not_warn(monkeypatch, capsys):
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_detect_model_family_lfm25_aliases(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_chat_handler_loading_prefers_declared_gemma3_handler(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
family_dash = module._detect_model_family("C:/models/LLM/LFM2.5-VL-Instruct.gguf")
|
||||
family_nodash = module._detect_model_family("C:/models/LLM/LFM2_5VL-Instruct.gguf")
|
||||
class Gemma3ChatHandler:
|
||||
pass
|
||||
|
||||
assert family_dash == "lfm2.5-vl"
|
||||
assert family_nodash == "lfm2.5-vl"
|
||||
class Gemma4ChatHandler:
|
||||
pass
|
||||
|
||||
handler_module = types.SimpleNamespace(
|
||||
Gemma3ChatHandler=Gemma3ChatHandler,
|
||||
Gemma4ChatHandler=Gemma4ChatHandler,
|
||||
)
|
||||
|
||||
handler_map, factory_map, registry = module._load_available_chat_handlers(
|
||||
{
|
||||
"gemma3": "Gemma3ChatHandler",
|
||||
"gemma4": "Gemma4ChatHandler",
|
||||
},
|
||||
module.CHAT_HANDLER_KWARGS_MAP,
|
||||
handler_module,
|
||||
)
|
||||
|
||||
assert handler_map["gemma3"] == "Gemma3ChatHandler"
|
||||
assert "gemma3" in factory_map
|
||||
assert registry["Gemma3ChatHandler"] is Gemma3ChatHandler
|
||||
|
||||
|
||||
def test_detect_model_family_minicpm_v46_aliases(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_chat_handler_loading_falls_back_to_gemma4_for_pypi_gemma3(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
family_dash = module._detect_model_family("C:/models/LLM/MiniCPM-V-4.6.gguf")
|
||||
family_underscore = module._detect_model_family("C:/models/LLM/MiniCPM-V-4_6.gguf")
|
||||
family_compact = module._detect_model_family("C:/models/LLM/MiniCPMV46.gguf")
|
||||
class Gemma4ChatHandler:
|
||||
pass
|
||||
|
||||
assert family_dash == "minicpm-v-4.6"
|
||||
assert family_underscore == "minicpm-v-4.6"
|
||||
assert family_compact == "minicpm-v-4.6"
|
||||
assert module.DECLARED_CHAT_HANDLER_MAP["minicpm-v-4.6"] == "MiniCPMV46ChatHandler"
|
||||
assert module.CHAT_HANDLER_KWARGS_MAP["minicpm-v-4.6"]["enable_thinking"] is False
|
||||
handler_module = types.SimpleNamespace(Gemma4ChatHandler=Gemma4ChatHandler)
|
||||
|
||||
handler_map, factory_map, registry = module._load_available_chat_handlers(
|
||||
{
|
||||
"gemma3": "Gemma3ChatHandler",
|
||||
"gemma4": "Gemma4ChatHandler",
|
||||
},
|
||||
module.CHAT_HANDLER_KWARGS_MAP,
|
||||
handler_module,
|
||||
)
|
||||
|
||||
assert handler_map["gemma3"] == "Gemma4ChatHandler"
|
||||
assert handler_map["gemma4"] == "Gemma4ChatHandler"
|
||||
assert "gemma3" in factory_map
|
||||
assert registry["Gemma4ChatHandler"] is Gemma4ChatHandler
|
||||
|
||||
|
||||
def test_chat_handler_loading_does_not_fallback_without_compat_handler(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
handler_map, factory_map, registry = module._load_available_chat_handlers(
|
||||
{
|
||||
"gemma3": "Gemma3ChatHandler",
|
||||
"gemma4": "Gemma4ChatHandler",
|
||||
},
|
||||
module.CHAT_HANDLER_KWARGS_MAP,
|
||||
types.SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert "gemma3" not in handler_map
|
||||
assert "gemma3" not in factory_map
|
||||
assert registry == {}
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_prefers_mmproj_path(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class NewHandler:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
NewHandler,
|
||||
"C:/models/mmproj-gemma4.gguf",
|
||||
{"enable_thinking": False},
|
||||
)
|
||||
|
||||
assert handler.kwargs == {
|
||||
"mmproj_path": "C:/models/mmproj-gemma4.gguf",
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_falls_back_to_clip_model_path(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class OldHandler:
|
||||
def __init__(self, **kwargs):
|
||||
if "mmproj_path" in kwargs:
|
||||
raise TypeError("got an unexpected keyword argument 'mmproj_path'")
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
OldHandler,
|
||||
"C:/models/mmproj-gemma4.gguf",
|
||||
{"enable_thinking": False},
|
||||
)
|
||||
|
||||
assert handler.kwargs == {
|
||||
"clip_model_path": "C:/models/mmproj-gemma4.gguf",
|
||||
"enable_thinking": False,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_falls_back_when_clip_model_path_is_required(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class RequiredClipPathHandler:
|
||||
def __init__(self, clip_model_path, **kwargs):
|
||||
self.clip_model_path = clip_model_path
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
RequiredClipPathHandler,
|
||||
"C:/models/mmproj-gemma4.gguf",
|
||||
{"enable_thinking": False},
|
||||
)
|
||||
|
||||
assert handler.clip_model_path == "C:/models/mmproj-gemma4.gguf"
|
||||
assert handler.kwargs == {"enable_thinking": False}
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_drops_unsupported_enable_thinking(load_nodes_module, monkeypatch, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
class PyPIStyleGemma4Handler:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
if "enable_thinking" in kwargs:
|
||||
raise TypeError("got an unexpected keyword argument 'enable_thinking'")
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
PyPIStyleGemma4Handler,
|
||||
"C:/models/mmproj-gemma4.gguf",
|
||||
{"enable_thinking": False},
|
||||
)
|
||||
|
||||
assert PyPIStyleGemma4Handler.calls == [
|
||||
{
|
||||
"mmproj_path": "C:/models/mmproj-gemma4.gguf",
|
||||
"enable_thinking": False,
|
||||
},
|
||||
{"mmproj_path": "C:/models/mmproj-gemma4.gguf"},
|
||||
]
|
||||
assert handler.kwargs == {"mmproj_path": "C:/models/mmproj-gemma4.gguf"}
|
||||
assert "enable_thinking" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_drops_unsupported_image_min_tokens(load_nodes_module, monkeypatch, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
class PyPIStyleQwen25VLHandler:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
if "image_min_tokens" in kwargs:
|
||||
raise TypeError("got an unexpected keyword argument 'image_min_tokens'")
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
PyPIStyleQwen25VLHandler,
|
||||
"C:/models/mmproj-qwen25vl.gguf",
|
||||
{"image_min_tokens": 1024},
|
||||
)
|
||||
|
||||
assert PyPIStyleQwen25VLHandler.calls == [
|
||||
{
|
||||
"mmproj_path": "C:/models/mmproj-qwen25vl.gguf",
|
||||
"image_min_tokens": 1024,
|
||||
},
|
||||
{"mmproj_path": "C:/models/mmproj-qwen25vl.gguf"},
|
||||
]
|
||||
assert handler.kwargs == {"mmproj_path": "C:/models/mmproj-qwen25vl.gguf"}
|
||||
assert "image_min_tokens" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_preserves_unrelated_type_errors(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class BadKwargHandler:
|
||||
def __init__(self, **_kwargs):
|
||||
raise TypeError("got an unexpected keyword argument 'bad_kwarg'")
|
||||
|
||||
with pytest.raises(TypeError, match="bad_kwarg"):
|
||||
module._instantiate_chat_handler(
|
||||
BadKwargHandler,
|
||||
"C:/models/mmproj-gemma4.gguf",
|
||||
{"bad_kwarg": True},
|
||||
)
|
||||
|
||||
|
||||
def _prepare_vision_manager_test(module, monkeypatch, handler_cls):
|
||||
@@ -115,8 +344,77 @@ def _prepare_vision_manager_test(module, monkeypatch, handler_cls):
|
||||
return DummyLlama
|
||||
|
||||
|
||||
def test_model_manager_keeps_text_fallback_when_vision_is_not_required(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_uses_mmproj_path_for_new_chat_handlers(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class NewHandler:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
dummy_llama = _prepare_vision_manager_test(module, monkeypatch, NewHandler)
|
||||
manager = module.GGUFModelManager()
|
||||
|
||||
manager.load_model(
|
||||
model_path="C:/models/Gemma-4-test.gguf",
|
||||
mmproj_path="C:/models/mmproj-gemma4.gguf",
|
||||
n_ctx=1024,
|
||||
n_gpu_layers=0,
|
||||
vision_required=True,
|
||||
)
|
||||
|
||||
assert NewHandler.calls
|
||||
assert NewHandler.calls[0]["mmproj_path"].replace("\\", "/") == "C:/models/mmproj-gemma4.gguf"
|
||||
assert "clip_model_path" not in NewHandler.calls[0]
|
||||
assert dummy_llama.calls
|
||||
assert "chat_handler" in dummy_llama.calls[0]
|
||||
|
||||
|
||||
def test_model_manager_reports_gemma3_compat_handler_fallback(load_nodes_module, monkeypatch, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
class CompatGemma4Handler:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
class DummyLlama:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "LLAMA_CPP_AVAILABLE", True)
|
||||
monkeypatch.setattr(module, "Llama", DummyLlama)
|
||||
monkeypatch.setattr(module, "chat_handler_factory_map", {"gemma3": object()})
|
||||
monkeypatch.setattr(module, "chat_handler_map", {"gemma3": "Gemma4ChatHandler"})
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"chat_handler_class_registry",
|
||||
{"Gemma4ChatHandler": CompatGemma4Handler},
|
||||
)
|
||||
manager = module.GGUFModelManager()
|
||||
|
||||
manager.load_model(
|
||||
model_path="C:/models/Gemma-3-test.gguf",
|
||||
mmproj_path="C:/models/mmproj-gemma3.gguf",
|
||||
n_ctx=1024,
|
||||
n_gpu_layers=0,
|
||||
vision_required=True,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Gemma3ChatHandler is unavailable" in captured.out
|
||||
assert "using Gemma4ChatHandler for gemma3 Vision compatibility" in captured.out
|
||||
assert CompatGemma4Handler.calls
|
||||
assert DummyLlama.calls
|
||||
assert "chat_handler" in DummyLlama.calls[0]
|
||||
|
||||
|
||||
def test_model_manager_keeps_text_fallback_when_vision_is_not_required(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class FailingHandler:
|
||||
def __init__(self, **_kwargs):
|
||||
@@ -137,8 +435,8 @@ def test_model_manager_keeps_text_fallback_when_vision_is_not_required(monkeypat
|
||||
assert "chat_handler" not in dummy_llama.calls[0]
|
||||
|
||||
|
||||
def test_model_manager_raises_when_required_mmproj_auto_detect_fails(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_raises_when_required_mmproj_auto_detect_fails(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyHandler:
|
||||
def __init__(self, **_kwargs):
|
||||
@@ -157,8 +455,8 @@ def test_model_manager_raises_when_required_mmproj_auto_detect_fails(monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_model_manager_raises_when_required_handler_initialization_fails(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_raises_when_required_handler_initialization_fails(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class FailingHandler:
|
||||
def __init__(self, **_kwargs):
|
||||
@@ -177,8 +475,8 @@ def test_model_manager_raises_when_required_handler_initialization_fails(monkeyp
|
||||
)
|
||||
|
||||
|
||||
def test_model_manager_reports_missing_required_handler_with_backend_guidance(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_reports_missing_required_handler_with_backend_guidance(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyLlama:
|
||||
calls = []
|
||||
@@ -212,8 +510,8 @@ def test_model_manager_reports_missing_required_handler_with_backend_guidance(mo
|
||||
assert DummyLlama.calls == []
|
||||
|
||||
|
||||
def test_model_manager_raises_when_required_family_is_not_supported(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_raises_when_required_family_is_not_supported(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyHandler:
|
||||
def __init__(self, **_kwargs):
|
||||
@@ -238,8 +536,8 @@ def test_model_manager_raises_when_required_family_is_not_supported(monkeypatch)
|
||||
assert "did not match any known multimodal family aliases" in msg
|
||||
|
||||
|
||||
def test_resolve_model_and_mmproj_raises_when_explicit_mmproj_is_missing(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_resolve_model_and_mmproj_raises_when_explicit_mmproj_is_missing(load_nodes_module, monkeypatch, tmp_path):
|
||||
module = load_nodes_module()
|
||||
model_path = tmp_path / "Gemma-4-test.gguf"
|
||||
model_path.write_text("dummy", encoding="utf-8")
|
||||
|
||||
|
||||
@@ -1,24 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
@pytest.mark.parametrize(
|
||||
("effort", "expected_prefix"),
|
||||
[
|
||||
("xhigh", "Reasoning effort is set to xhigh."),
|
||||
("low", "Reasoning effort is set to low."),
|
||||
],
|
||||
)
|
||||
def test_qwen38_reasoning_effort_prefixes_original_system_prompt(
|
||||
load_nodes_module, effort, expected_prefix
|
||||
):
|
||||
module = load_nodes_module()
|
||||
|
||||
effective = module._build_effective_system_prompt(
|
||||
model_path="C:/models/Qwen3.8-27B.gguf",
|
||||
system_prompt="User system rules.",
|
||||
enable_thinking=True,
|
||||
reasoning_effort=effort,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
assert effective.startswith(expected_prefix)
|
||||
assert effective.endswith("\n\nUser system rules.")
|
||||
|
||||
|
||||
def test_gemma4_text_builder_consumes_enable_thinking_false(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
@pytest.mark.parametrize(
|
||||
("model_path", "enable_thinking", "effort"),
|
||||
[
|
||||
("C:/models/Qwen3.8-27B.gguf", True, "medium"),
|
||||
("C:/models/Qwen3.8-27B.gguf", False, "xhigh"),
|
||||
("C:/models/Qwen3.5-27B.gguf", True, "xhigh"),
|
||||
("C:/models/Qwen3.6-27B.gguf", True, "low"),
|
||||
],
|
||||
)
|
||||
def test_reasoning_effort_does_not_change_other_prompt_modes(
|
||||
load_nodes_module, model_path, enable_thinking, effort
|
||||
):
|
||||
module = load_nodes_module()
|
||||
|
||||
assert module._build_effective_system_prompt(
|
||||
model_path=model_path,
|
||||
system_prompt="User system rules.",
|
||||
enable_thinking=enable_thinking,
|
||||
reasoning_effort=effort,
|
||||
) == "User system rules."
|
||||
|
||||
|
||||
def test_qwen38_text_builder_consumes_enable_thinking_false(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
messages = [
|
||||
{"role": "system", "content": "System rules."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
request = module._build_text_chat_request(
|
||||
model_path="C:/models/LLM/Qwen3.8-27B-Q4_K_M.gguf",
|
||||
mmproj_path=module._MMPROJ_NOT_REQUIRED,
|
||||
messages=messages,
|
||||
text_chat_builder_overrides={"qwen3.8": {"enable_thinking": False}},
|
||||
)
|
||||
|
||||
assert request is not None
|
||||
assert request["model_family"] == "qwen3.8"
|
||||
assert request["config"]["enable_thinking"] is False
|
||||
assert "<|im_start|>system\nSystem rules.<|im_end|>" in request["prompt"]
|
||||
assert "<|im_start|>user\nHello<|im_end|>" in request["prompt"]
|
||||
assert request["prompt"].endswith("<|im_start|>assistant\n<think>\n\n</think>\n\n")
|
||||
assert request["stop"] == ["<|im_end|>", "<|endoftext|>"]
|
||||
|
||||
|
||||
def test_qwen38_text_builder_consumes_enable_thinking_true(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
request = module._build_text_chat_request(
|
||||
model_path="C:/models/LLM/Qwen-3.8-27B-Q4_K_M.gguf",
|
||||
mmproj_path="",
|
||||
messages=[{"role": "user", "content": "Think if needed."}],
|
||||
text_chat_builder_overrides={"qwen3.8": {"enable_thinking": True}},
|
||||
)
|
||||
|
||||
assert request is not None
|
||||
assert request["model_family"] == "qwen3.8"
|
||||
assert request["config"]["enable_thinking"] is True
|
||||
assert request["prompt"].endswith("<|im_start|>assistant\n<think>\n")
|
||||
assert "</think>" not in request["prompt"]
|
||||
|
||||
|
||||
def test_qwen38_summary_forced_override_wins_over_request_override(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
overrides = module._merge_text_chat_builder_overrides(
|
||||
model_path="C:/models/LLM/Qwen3_8-27B-Q4_K_M.gguf",
|
||||
base_overrides={"qwen3.8": {"enable_thinking": True}},
|
||||
forced_overrides_map=module.SUMMARY_TEXT_CHAT_BUILDER_FORCE_MAP,
|
||||
)
|
||||
|
||||
request = module._build_text_chat_request(
|
||||
model_path="C:/models/LLM/Qwen3_8-27B-Q4_K_M.gguf",
|
||||
mmproj_path=None,
|
||||
messages=[{"role": "user", "content": "Summarize."}],
|
||||
text_chat_builder_overrides=overrides,
|
||||
)
|
||||
|
||||
assert overrides == {"qwen3.8": {"enable_thinking": False}}
|
||||
assert request is not None
|
||||
assert request["config"]["enable_thinking"] is False
|
||||
assert request["prompt"].endswith("<|im_start|>assistant\n<think>\n\n</think>\n\n")
|
||||
|
||||
|
||||
|
||||
def test_gemma4_text_builder_consumes_enable_thinking_false(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
messages = [
|
||||
{"role": "system", "content": "System rules."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -41,8 +135,8 @@ def test_gemma4_text_builder_consumes_enable_thinking_false(monkeypatch):
|
||||
assert request["stop"] == ["<end_of_turn>", "<eos>"]
|
||||
|
||||
|
||||
def test_gemma4_text_builder_consumes_enable_thinking_true(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_gemma4_text_builder_consumes_enable_thinking_true(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
messages = [{"role": "user", "content": "Think if needed."}]
|
||||
|
||||
request = module._build_text_chat_request(
|
||||
@@ -59,8 +153,8 @@ def test_gemma4_text_builder_consumes_enable_thinking_true(monkeypatch):
|
||||
assert "<|channel>thought\n<channel|>" not in request["prompt"]
|
||||
|
||||
|
||||
def test_gemma4_summary_forced_override_wins_over_request_override(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_gemma4_summary_forced_override_wins_over_request_override(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
overrides = module._merge_text_chat_builder_overrides(
|
||||
model_path="C:/models/LLM/gemma-4-31B-it.gguf",
|
||||
@@ -83,8 +177,8 @@ def test_gemma4_summary_forced_override_wins_over_request_override(monkeypatch):
|
||||
assert "<|channel>thought\n<channel|>" not in request["prompt"]
|
||||
|
||||
|
||||
def test_minicpm_v46_text_builder_consumes_enable_thinking_false(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_minicpm_v46_text_builder_consumes_enable_thinking_false(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
messages = [
|
||||
{"role": "system", "content": "System rules."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
@@ -106,8 +200,8 @@ def test_minicpm_v46_text_builder_consumes_enable_thinking_false(monkeypatch):
|
||||
assert request["stop"] == ["<|endoftext|>", "<|im_end|>"]
|
||||
|
||||
|
||||
def test_minicpm_v46_text_builder_consumes_enable_thinking_true(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_minicpm_v46_text_builder_consumes_enable_thinking_true(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
messages = [{"role": "user", "content": "Think if needed."}]
|
||||
|
||||
request = module._build_text_chat_request(
|
||||
@@ -123,8 +217,8 @@ def test_minicpm_v46_text_builder_consumes_enable_thinking_true(monkeypatch):
|
||||
assert "</think>" not in request["prompt"]
|
||||
|
||||
|
||||
def test_minicpm_v46_summary_forced_override_wins_over_request_override(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_minicpm_v46_summary_forced_override_wins_over_request_override(load_nodes_module):
|
||||
module = load_nodes_module()
|
||||
|
||||
overrides = module._merge_text_chat_builder_overrides(
|
||||
model_path="C:/models/LLM/minicpm-v-4_6.gguf",
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
class DummyManager:
|
||||
pass
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
class UnloadTrackingManager:
|
||||
def __init__(self) -> None:
|
||||
self.unload_calls = 0
|
||||
self.unloaded = False
|
||||
|
||||
def unload_model(self):
|
||||
self.unload_calls += 1
|
||||
self.unloaded = True
|
||||
|
||||
|
||||
def test_turn_execution_dependencies_use_injected_runtime_container(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
|
||||
def test_turn_execution_dependencies_use_injected_runtime_container(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
container = module.RuntimeContainer(model_manager=None, mem_kv_state={"sid": {"k": "v"}})
|
||||
|
||||
deps = module._build_turn_execution_dependencies(runtime_container=container)
|
||||
@@ -28,11 +27,8 @@ def test_turn_execution_dependencies_use_injected_runtime_container(monkeypatch)
|
||||
assert "sid" not in container.mem_kv_state
|
||||
|
||||
|
||||
def test_session_chat_dependencies_reuse_injected_model_manager(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
|
||||
class DummyManager:
|
||||
pass
|
||||
def test_session_chat_dependencies_reuse_injected_model_manager(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
manager = DummyManager()
|
||||
container = module.RuntimeContainer(model_manager=manager, mem_kv_state={})
|
||||
@@ -42,17 +38,10 @@ def test_session_chat_dependencies_reuse_injected_model_manager(monkeypatch):
|
||||
assert deps.get_or_create_model_manager() is manager
|
||||
|
||||
|
||||
def test_cleanup_unloads_runtime_container_model_manager(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_cleanup_unloads_runtime_container_model_manager(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyManager:
|
||||
def __init__(self) -> None:
|
||||
self.unloaded = False
|
||||
|
||||
def unload_model(self):
|
||||
self.unloaded = True
|
||||
|
||||
manager = DummyManager()
|
||||
manager = UnloadTrackingManager()
|
||||
container = module.RuntimeContainer(model_manager=manager, mem_kv_state={})
|
||||
monkeypatch.setattr(module, "_runtime_container", container)
|
||||
|
||||
@@ -61,11 +50,8 @@ def test_cleanup_unloads_runtime_container_model_manager(monkeypatch):
|
||||
assert manager.unloaded is True
|
||||
assert container.model_manager is None
|
||||
|
||||
def test_resolve_runtime_container_lazy_initializes_default(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
|
||||
class DummyManager:
|
||||
pass
|
||||
def test_resolve_runtime_container_lazy_initializes_default(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
monkeypatch.setattr(module, "GGUFModelManager", DummyManager)
|
||||
monkeypatch.setattr(module, "_runtime_container", None)
|
||||
@@ -76,25 +62,18 @@ def test_resolve_runtime_container_lazy_initializes_default(monkeypatch):
|
||||
assert module._runtime_container is container
|
||||
|
||||
|
||||
def test_cleanup_skips_when_default_runtime_container_not_initialized(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_cleanup_skips_when_default_runtime_container_not_initialized(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
monkeypatch.setattr(module, "_runtime_container", None)
|
||||
|
||||
module.cleanup()
|
||||
|
||||
assert module._runtime_container is None
|
||||
|
||||
def test_unload_node_uses_runtime_container_manager(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_unload_node_uses_runtime_container_manager(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyManager:
|
||||
def __init__(self) -> None:
|
||||
self.unload_calls = 0
|
||||
|
||||
def unload_model(self):
|
||||
self.unload_calls += 1
|
||||
|
||||
manager = DummyManager()
|
||||
manager = UnloadTrackingManager()
|
||||
container = module.RuntimeContainer(model_manager=manager, mem_kv_state={})
|
||||
monkeypatch.setattr(module, "_runtime_container", container)
|
||||
node = module.UnloadLLMModelNode()
|
||||
@@ -105,8 +84,8 @@ def test_unload_node_uses_runtime_container_manager(monkeypatch):
|
||||
assert manager.unload_calls == 1
|
||||
|
||||
|
||||
def test_unload_model_clears_runtime_container_mem_kv_state(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_unload_model_clears_runtime_container_mem_kv_state(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
manager = module.GGUFModelManager()
|
||||
manager.model = object()
|
||||
manager.chat_handler = object()
|
||||
@@ -134,8 +113,8 @@ def test_unload_model_clears_runtime_container_mem_kv_state(monkeypatch):
|
||||
assert manager._signature is None
|
||||
assert container.mem_kv_state == {}
|
||||
|
||||
def test_dialogue_cycle_model_manager_reuse_in_runtime_container(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_dialogue_cycle_model_manager_reuse_in_runtime_container(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
container = module.RuntimeContainer(model_manager=None, mem_kv_state={})
|
||||
|
||||
manager_a_1 = module._get_or_create_dialogue_cycle_model_manager("A", runtime_container=container)
|
||||
@@ -147,19 +126,46 @@ def test_dialogue_cycle_model_manager_reuse_in_runtime_container(monkeypatch):
|
||||
assert set(container.dialogue_model_managers.keys()) == {"A", "B"}
|
||||
|
||||
|
||||
def test_unload_node_unloads_dialogue_cycle_managers(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_chat_one_turn_forwards_dialogue_log_prefix_override(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class DummyManager:
|
||||
def __init__(self) -> None:
|
||||
self.unload_calls = 0
|
||||
def _execute_dialogue_cycle_turn(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return module.TurnExecutionResult(
|
||||
assistant_text="ok",
|
||||
generation_succeeded=True,
|
||||
)
|
||||
|
||||
def unload_model(self):
|
||||
self.unload_calls += 1
|
||||
monkeypatch.setattr(module, "_require_llama_cpp_available", lambda: None)
|
||||
monkeypatch.setattr(module, "_get_or_create_model_manager", lambda _manager=None: DummyManager())
|
||||
monkeypatch.setattr(module, "_execute_dialogue_cycle_turn", _execute_dialogue_cycle_turn)
|
||||
|
||||
shared = DummyManager()
|
||||
manager_a = DummyManager()
|
||||
manager_b = DummyManager()
|
||||
result = module._chat_one_turn(
|
||||
user_text="hello",
|
||||
session_id="sid_A",
|
||||
model="model.gguf",
|
||||
mmproj="(Auto detect)",
|
||||
system_prompt="sys",
|
||||
max_tokens=64,
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
n_gpu_layers=0,
|
||||
tensor_split=None,
|
||||
n_ctx=1024,
|
||||
log_prefix_override="[LLM Dialogue Cycle A/1]",
|
||||
)
|
||||
|
||||
assert result == "ok"
|
||||
assert captured["log_prefix_override"] == "[LLM Dialogue Cycle A/1]"
|
||||
|
||||
|
||||
def test_unload_node_unloads_dialogue_cycle_managers(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
shared = UnloadTrackingManager()
|
||||
manager_a = UnloadTrackingManager()
|
||||
manager_b = UnloadTrackingManager()
|
||||
container = module.RuntimeContainer(
|
||||
model_manager=shared,
|
||||
mem_kv_state={},
|
||||
@@ -177,19 +183,12 @@ def test_unload_node_unloads_dialogue_cycle_managers(monkeypatch):
|
||||
assert container.model_manager is None
|
||||
assert container.dialogue_model_managers == {}
|
||||
|
||||
def test_cleanup_unloads_dialogue_cycle_managers(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_cleanup_unloads_dialogue_cycle_managers(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
class DummyManager:
|
||||
def __init__(self) -> None:
|
||||
self.unloaded = False
|
||||
|
||||
def unload_model(self):
|
||||
self.unloaded = True
|
||||
|
||||
shared = DummyManager()
|
||||
manager_a = DummyManager()
|
||||
manager_b = DummyManager()
|
||||
shared = UnloadTrackingManager()
|
||||
manager_a = UnloadTrackingManager()
|
||||
manager_b = UnloadTrackingManager()
|
||||
container = module.RuntimeContainer(
|
||||
model_manager=shared,
|
||||
mem_kv_state={},
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
|
||||
from core.defaults import SIMPLE_WRAPPER_DEFAULTS
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
|
||||
def test_simple_wrapper_signature_defaults(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_wrapper_signature_defaults(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
chat_sig = inspect.signature(module.LLMSessionChatSimpleNode.chat_stream)
|
||||
cycle_sig = inspect.signature(module.LLMDialogueCycleSimpleNode.chat_cycle_simple)
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
|
||||
def test_strip_reasoning_output_handles_gemma4_channel_delimiter(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_strip_reasoning_output_handles_gemma4_channel_delimiter(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
raw = (
|
||||
"<|channel>thought\n"
|
||||
"hogehoge\n"
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
|
||||
from core.defaults import FULL_UI_DEFAULTS, SUMMARY_HELPER_DEFAULTS
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
|
||||
def test_summary_helper_function_defaults_are_centralized(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_summary_helper_function_defaults_are_centralized(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
|
||||
compact_sig = inspect.signature(module.maybe_compact_summary)
|
||||
summarize_sig = inspect.signature(module.maybe_summarize_history)
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _load_nodes_module(monkeypatch):
|
||||
fake_folder_paths = types.SimpleNamespace(
|
||||
models_dir="C:/models",
|
||||
get_folder_paths=lambda _key: [],
|
||||
get_filename_list=lambda _key: [],
|
||||
get_output_directory=lambda: "C:/output",
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
|
||||
sys.modules.pop("llm_session_nodes", None)
|
||||
return importlib.import_module("llm_session_nodes")
|
||||
|
||||
|
||||
def test_simple_defaults_accept_tensor_split(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_accept_tensor_split(load_nodes_module, monkeypatch, tmp_path):
|
||||
module = load_nodes_module()
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
@@ -37,8 +23,8 @@ def test_simple_defaults_accept_tensor_split(monkeypatch, tmp_path):
|
||||
assert defaults["tensor_split"] == [1.0, 0.0]
|
||||
|
||||
|
||||
def test_simple_defaults_ignore_invalid_tensor_split(monkeypatch, tmp_path):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_simple_defaults_ignore_invalid_tensor_split(load_nodes_module, monkeypatch, tmp_path):
|
||||
module = load_nodes_module()
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
@@ -55,8 +41,8 @@ def test_simple_defaults_ignore_invalid_tensor_split(monkeypatch, tmp_path):
|
||||
assert defaults["tensor_split"] is None
|
||||
|
||||
|
||||
def test_model_manager_passes_tensor_split_to_llama(monkeypatch):
|
||||
module = _load_nodes_module(monkeypatch)
|
||||
def test_model_manager_passes_tensor_split_to_llama(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module()
|
||||
calls = []
|
||||
|
||||
class DummyLlama:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Record:
|
||||
kind: str
|
||||
id: str | None
|
||||
options: dict
|
||||
|
||||
|
||||
def _fake_type(kind: str):
|
||||
return types.SimpleNamespace(
|
||||
Input=lambda input_id, **kwargs: _Record(kind, input_id, kwargs),
|
||||
Output=lambda output_id=None, **kwargs: _Record(kind, output_id, kwargs),
|
||||
)
|
||||
|
||||
|
||||
class _FakeTemplatePrefix:
|
||||
def __init__(self, *, input, prefix, min, max):
|
||||
self.input = input
|
||||
self.prefix = prefix
|
||||
self.min = min
|
||||
self.max = max
|
||||
|
||||
|
||||
class _FakeSchema:
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
class _FakeNodeOutput:
|
||||
def __init__(self, *values):
|
||||
self.values = values
|
||||
|
||||
|
||||
def _fake_comfy_io():
|
||||
fake_io = types.SimpleNamespace(
|
||||
ComfyNode=object,
|
||||
Schema=_FakeSchema,
|
||||
NodeOutput=_FakeNodeOutput,
|
||||
String=_fake_type("STRING"),
|
||||
Int=_fake_type("INT"),
|
||||
Float=_fake_type("FLOAT"),
|
||||
Boolean=_fake_type("BOOLEAN"),
|
||||
Combo=_fake_type("COMBO"),
|
||||
AnyType=_fake_type("*"),
|
||||
Image=_fake_type("IMAGE"),
|
||||
Audio=_fake_type("AUDIO"),
|
||||
MultiType=types.SimpleNamespace(
|
||||
Input=lambda input_id, **kwargs: _Record("COMFY_MULTITYPED_V3", input_id, kwargs)
|
||||
),
|
||||
Autogrow=types.SimpleNamespace(
|
||||
Input=lambda input_id, **kwargs: _Record("COMFY_AUTOGROW_V3", input_id, kwargs),
|
||||
TemplatePrefix=_FakeTemplatePrefix,
|
||||
),
|
||||
)
|
||||
fake_io.Custom = lambda io_type: _fake_type(io_type)
|
||||
return fake_io
|
||||
|
||||
|
||||
def test_v3_adapters_expose_autogrow_media_and_preserve_node_ids(
|
||||
load_nodes_module,
|
||||
monkeypatch,
|
||||
):
|
||||
fake_io = _fake_comfy_io()
|
||||
comfy_api_module = types.ModuleType("comfy_api")
|
||||
comfy_latest_module = types.ModuleType("comfy_api.latest")
|
||||
comfy_latest_module.io = fake_io
|
||||
comfy_api_module.latest = comfy_latest_module
|
||||
monkeypatch.setitem(sys.modules, "comfy_api", comfy_api_module)
|
||||
monkeypatch.setitem(sys.modules, "comfy_api.latest", comfy_latest_module)
|
||||
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
full_adapter = module.NODE_CLASS_MAPPINGS["LLMSessionChatNode"]
|
||||
simple_adapter = module.NODE_CLASS_MAPPINGS["LLMSessionChatSimpleNode"]
|
||||
assert full_adapter is module.LLMSessionChatV3Adapter
|
||||
assert simple_adapter is module.LLMSessionChatSimpleV3Adapter
|
||||
|
||||
for adapter, expected_node_id in (
|
||||
(full_adapter, "LLMSessionChatNode"),
|
||||
(simple_adapter, "LLMSessionChatSimpleNode"),
|
||||
):
|
||||
schema = adapter.define_schema()
|
||||
assert schema.node_id == expected_node_id
|
||||
assert schema.accept_all_inputs is True
|
||||
autogrow = next(item for item in schema.inputs if item.kind == "COMFY_AUTOGROW_V3")
|
||||
assert autogrow.id == "media_inputs"
|
||||
template = autogrow.options["template"]
|
||||
assert template.prefix == "media_"
|
||||
assert template.min == 0
|
||||
assert template.max == 9
|
||||
assert template.input.kind == "COMFY_MULTITYPED_V3"
|
||||
assert template.input.options["types"] == [fake_io.Image, fake_io.Audio]
|
||||
user_text = next(item for item in schema.inputs if item.id == "user_text")
|
||||
assert user_text.kind == "STRING"
|
||||
assert user_text.options["default"] == ""
|
||||
assert user_text.options["multiline"] is True
|
||||
|
||||
|
||||
def test_legacy_nodes_remain_mapped_when_v3_autogrow_is_unavailable(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
assert module.LLMSessionChatV3Adapter is None
|
||||
assert module.LLMSessionChatSimpleV3Adapter is None
|
||||
assert module.NODE_CLASS_MAPPINGS["LLMSessionChatNode"] is module.LLMSessionChatNode
|
||||
assert (
|
||||
module.NODE_CLASS_MAPPINGS["LLMSessionChatSimpleNode"]
|
||||
is module.LLMSessionChatSimpleNode
|
||||
)
|
||||
|
||||
|
||||
def test_v3_adapter_prefers_autogrow_media_over_legacy_media(
|
||||
load_nodes_module,
|
||||
monkeypatch,
|
||||
):
|
||||
fake_io = _fake_comfy_io()
|
||||
comfy_api_module = types.ModuleType("comfy_api")
|
||||
comfy_latest_module = types.ModuleType("comfy_api.latest")
|
||||
comfy_latest_module.io = fake_io
|
||||
comfy_api_module.latest = comfy_latest_module
|
||||
monkeypatch.setitem(sys.modules, "comfy_api", comfy_api_module)
|
||||
monkeypatch.setitem(sys.modules, "comfy_api.latest", comfy_latest_module)
|
||||
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
module.LLMSessionChatNode,
|
||||
"chat_stream",
|
||||
lambda self, *, media=None, **kwargs: captured.setdefault("media", media) or ("ok",),
|
||||
)
|
||||
first = object()
|
||||
second = object()
|
||||
|
||||
module.LLMSessionChatV3Adapter.execute(
|
||||
user_text="hello",
|
||||
media_inputs={"media_1": second, "media_0": first},
|
||||
media=object(),
|
||||
)
|
||||
|
||||
assert captured["media"] == (first, second)
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class FakeTensor:
|
||||
def __init__(self, array):
|
||||
self._array = np.asarray(array, dtype=np.float32)
|
||||
self.shape = self._array.shape
|
||||
|
||||
def size(self, dim):
|
||||
return self._array.shape[dim]
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
return self._array
|
||||
|
||||
def __getitem__(self, index):
|
||||
return FakeTensor(self._array[index])
|
||||
|
||||
|
||||
def _write_config(tmp_path, config):
|
||||
config_path = tmp_path / "simple_defaults.json"
|
||||
config_path.write_text(json.dumps({"schema_version": 1, **config}), encoding="utf-8")
|
||||
return str(config_path)
|
||||
|
||||
|
||||
def _encoded_image_size(message_content):
|
||||
image_part = next(part for part in message_content if part["type"] == "image_url")
|
||||
encoded = image_part["image_url"]["url"].split(",", 1)[1]
|
||||
return Image.open(io.BytesIO(base64.b64decode(encoded))).size
|
||||
|
||||
|
||||
def test_simple_defaults_keep_legacy_image_max_pixels_when_unset(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(_write_config(tmp_path, {}))
|
||||
|
||||
assert defaults["image_max_pixels"] == 262144
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "expected"),
|
||||
[
|
||||
(1048576, 1048576),
|
||||
(1000, 65536),
|
||||
(99999999, 4194304),
|
||||
],
|
||||
)
|
||||
def test_simple_defaults_clamp_image_max_pixels(load_nodes_module, tmp_path, configured, expected):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_generation_kwargs": {"image_max_pixels": configured}})
|
||||
)
|
||||
|
||||
assert defaults["image_max_pixels"] == expected
|
||||
|
||||
|
||||
def test_simple_defaults_invalid_image_max_pixels_warns_and_uses_legacy_budget(
|
||||
load_nodes_module, tmp_path, capsys
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"advanced_generation_kwargs": {"image_max_pixels": "large"}})
|
||||
)
|
||||
|
||||
assert defaults["image_max_pixels"] == 262144
|
||||
assert "Invalid advanced_generation_kwargs.image_max_pixels" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_simple_defaults_keep_image_max_pixels_out_of_backend_generation_kwargs(
|
||||
load_nodes_module, tmp_path, capsys
|
||||
):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(
|
||||
tmp_path,
|
||||
{"advanced_generation_kwargs": {"seed": 7, "image_max_pixels": 1048576}},
|
||||
)
|
||||
)
|
||||
|
||||
assert defaults["image_max_pixels"] == 1048576
|
||||
assert defaults["advanced_generation_kwargs"] == {"seed": 7}
|
||||
assert "unsupported" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_simple_defaults_ignore_top_level_image_max_pixels(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(_write_config(tmp_path, {"image_max_pixels": 1048576}))
|
||||
|
||||
assert defaults["image_max_pixels"] == 262144
|
||||
|
||||
|
||||
def test_gemma4_chat_handler_kwargs_fix_image_max_tokens_to_ubatch_size(load_nodes_module):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
kwargs = module._get_chat_handler_kwargs("gemma4", {"gemma4": {"enable_thinking": True}})
|
||||
|
||||
assert kwargs == {"enable_thinking": True, "image_max_tokens": 512}
|
||||
|
||||
|
||||
def test_simple_defaults_ignore_gemma4_image_max_tokens(load_nodes_module, tmp_path):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
|
||||
defaults = module._load_simple_defaults(
|
||||
_write_config(tmp_path, {"gemma4": {"image_max_tokens": 1120}})
|
||||
)
|
||||
kwargs = module._get_chat_handler_kwargs("gemma4", defaults["chat_handler_overrides"])
|
||||
|
||||
assert kwargs["image_max_tokens"] == 512
|
||||
|
||||
|
||||
def test_session_chat_simple_forwards_image_max_pixels(load_nodes_module, tmp_path, monkeypatch):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
observed = {}
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"_run_session_chat_from_inputs",
|
||||
lambda **kwargs: observed.update(kwargs) or ("ok",),
|
||||
)
|
||||
|
||||
module.LLMSessionChatSimpleNode().chat_stream(
|
||||
user_text="describe",
|
||||
session_id="sid",
|
||||
model="dummy.gguf",
|
||||
mmproj="(Auto-detect)",
|
||||
history_dir="",
|
||||
config_path=_write_config(tmp_path, {"advanced_generation_kwargs": {"image_max_pixels": 1048576}}),
|
||||
)
|
||||
|
||||
assert observed["image_max_pixels"] == 1048576
|
||||
|
||||
|
||||
def test_session_chat_turn_kwargs_reach_turn_execution_request(load_nodes_module, monkeypatch):
|
||||
module = load_nodes_module(available_models=["dummy.gguf"])
|
||||
observed = {}
|
||||
monkeypatch.setattr(
|
||||
module.TurnExecutionService,
|
||||
"execute_turn",
|
||||
lambda _self, request: observed.setdefault("request", request),
|
||||
)
|
||||
defaults = module._load_simple_defaults(None)
|
||||
chat_kwargs = module._build_session_chat_simple_chat_kwargs(
|
||||
defaults=defaults,
|
||||
model="dummy.gguf",
|
||||
history_dir="",
|
||||
chat_handler_overrides=None,
|
||||
text_chat_builder_overrides=None,
|
||||
)
|
||||
chat_kwargs["image_max_pixels"] = 1048576
|
||||
node_request = module._build_session_chat_node_execution_request(
|
||||
user_text="describe",
|
||||
session_id="sid",
|
||||
model="dummy.gguf",
|
||||
mmproj="(Auto-detect)",
|
||||
media=None,
|
||||
enable_thinking=False,
|
||||
official_sampling_profile="",
|
||||
**chat_kwargs,
|
||||
)
|
||||
|
||||
module._execute_session_chat_turn(**node_request.turn_kwargs)
|
||||
|
||||
assert observed["request"].image_max_pixels == 1048576
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("image_max_pixels", "expected_size"),
|
||||
[
|
||||
(None, (512, 512)),
|
||||
(1048576, (1024, 1024)),
|
||||
],
|
||||
)
|
||||
def test_build_chat_messages_downscales_images_to_image_max_pixels(
|
||||
load_nodes_module, image_max_pixels, expected_size
|
||||
):
|
||||
module = load_nodes_module()
|
||||
image = FakeTensor(np.zeros((1, 1024, 1024, 3), dtype=np.float32))
|
||||
|
||||
messages = module.build_chat_messages(
|
||||
history={"turns": []},
|
||||
user_text="describe",
|
||||
media=image,
|
||||
model_path="C:/models/gemma-4-26B-A4B-it.gguf",
|
||||
image_max_pixels=image_max_pixels,
|
||||
)
|
||||
|
||||
assert _encoded_image_size(messages[-1]["content"]) == expected_size
|
||||
|
||||
|
||||
def test_chat_handler_instantiation_drops_unsupported_image_max_tokens(load_nodes_module, capsys):
|
||||
module = load_nodes_module()
|
||||
|
||||
class OlderGemma4Handler:
|
||||
calls = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
if "image_max_tokens" in kwargs:
|
||||
raise TypeError("got an unexpected keyword argument 'image_max_tokens'")
|
||||
self.kwargs = kwargs
|
||||
|
||||
handler = module._instantiate_chat_handler(
|
||||
OlderGemma4Handler,
|
||||
"C:/models/mmproj-gemma-4.gguf",
|
||||
{"enable_thinking": True, "image_max_tokens": 448},
|
||||
)
|
||||
|
||||
assert handler.kwargs == {"mmproj_path": "C:/models/mmproj-gemma-4.gguf", "enable_thinking": True}
|
||||
assert "image_max_tokens" in capsys.readouterr().out
|
||||
@@ -0,0 +1,175 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
const SESSION_CHAT_NODE_TYPES = new Set([
|
||||
"LLMSessionChatNode",
|
||||
"LLMSessionChatSimpleNode",
|
||||
]);
|
||||
const LITEGRAPH_INPUT = 1;
|
||||
const AUTOGROW_MEDIA_INPUT_RE = /^media_inputs\.(media_\d+)$/;
|
||||
|
||||
function hasLink(input) {
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
if (input.link != null) {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(input.links) && input.links.length > 0;
|
||||
}
|
||||
|
||||
function renameInput(input, name, targetInput = null, localizedName = name) {
|
||||
input.name = name;
|
||||
input.label = localizedName;
|
||||
input.localized_name = targetInput?.localized_name ?? name;
|
||||
input.type = targetInput?.type ?? "*";
|
||||
}
|
||||
|
||||
function normalizeAutogrowMediaLabels(node) {
|
||||
let changed = false;
|
||||
for (const input of node.inputs) {
|
||||
const match = AUTOGROW_MEDIA_INPUT_RE.exec(input?.name ?? "");
|
||||
if (!match || input.label === match[1]) {
|
||||
continue;
|
||||
}
|
||||
input.label = match[1];
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function removeInput(node, slot) {
|
||||
if (slot < 0 || !node.inputs || slot >= node.inputs.length) {
|
||||
return;
|
||||
}
|
||||
if (typeof node.removeInput === "function") {
|
||||
node.removeInput(slot);
|
||||
} else {
|
||||
node.inputs.splice(slot, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function findInputSlot(node, name) {
|
||||
if (typeof node.findInputSlot === "function") {
|
||||
return node.findInputSlot(name);
|
||||
}
|
||||
return node.inputs?.findIndex((input) => input?.name === name) ?? -1;
|
||||
}
|
||||
|
||||
function migrateInputName(node, fromName, toName, localizedName = toName) {
|
||||
let fromSlot = findInputSlot(node, fromName);
|
||||
let toSlot = findInputSlot(node, toName);
|
||||
if (fromSlot < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (toSlot < 0) {
|
||||
renameInput(node.inputs[fromSlot], toName, null, localizedName);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fromInput = node.inputs[fromSlot];
|
||||
const toInput = node.inputs[toSlot];
|
||||
const fromHasLink = hasLink(fromInput);
|
||||
const toHasLink = hasLink(toInput);
|
||||
|
||||
if (fromHasLink && !toHasLink) {
|
||||
removeInput(node, toSlot);
|
||||
fromSlot = findInputSlot(node, fromName);
|
||||
if (fromSlot >= 0) {
|
||||
renameInput(node.inputs[fromSlot], toName, toInput, localizedName);
|
||||
}
|
||||
} else {
|
||||
if (fromHasLink && toHasLink) {
|
||||
console.warn(
|
||||
`[ComfyUI-LLM-Session] Both ${fromName} and ${toName} inputs had links; keeping ${toName} and removing ${fromName}.`,
|
||||
node,
|
||||
);
|
||||
}
|
||||
removeInput(node, fromSlot);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function migrateLegacyMediaInputs(node, usesAutogrow) {
|
||||
if (!node || !SESSION_CHAT_NODE_TYPES.has(node.comfyClass || node.type)) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(node.inputs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let changed = migrateInputName(node, "image", "media");
|
||||
if (usesAutogrow) {
|
||||
changed = migrateInputName(
|
||||
node,
|
||||
"media",
|
||||
"media_inputs.media_0",
|
||||
"media_0",
|
||||
) || changed;
|
||||
changed = normalizeAutogrowMediaLabels(node) || changed;
|
||||
}
|
||||
if (changed) {
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
function hasMediaAutogrow(nodeData) {
|
||||
const inputGroups = [nodeData?.input?.required, nodeData?.input?.optional];
|
||||
return inputGroups.some((group) =>
|
||||
Object.values(group ?? {}).some((spec) => spec?.[0] === "COMFY_AUTOGROW_V3"),
|
||||
);
|
||||
}
|
||||
|
||||
function notifyAutogrowOfMigratedLink(node) {
|
||||
const notify = () => {
|
||||
const slot = findInputSlot(node, "media_inputs.media_0");
|
||||
const input = slot >= 0 ? node.inputs[slot] : null;
|
||||
const graphLinks = node.graph?.links;
|
||||
const link = input?.link != null
|
||||
? (graphLinks?.get?.(input.link) ?? graphLinks?.[input.link])
|
||||
: null;
|
||||
if (input && link) {
|
||||
node.onConnectionsChange?.(LITEGRAPH_INPUT, slot, true, link, input);
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(notify);
|
||||
} else {
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI-LLM-Session.MediaInputMigration",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (!SESSION_CHAT_NODE_TYPES.has(nodeData.name)) {
|
||||
return;
|
||||
}
|
||||
const usesAutogrow = hasMediaAutogrow(nodeData);
|
||||
nodeType.prototype.__llmSessionUsesMediaAutogrow = usesAutogrow;
|
||||
|
||||
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
const result = originalOnNodeCreated?.apply(this, args);
|
||||
migrateLegacyMediaInputs(this, usesAutogrow);
|
||||
return result;
|
||||
};
|
||||
|
||||
const originalConfigure = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function (...args) {
|
||||
const result = originalConfigure?.apply(this, args);
|
||||
migrateLegacyMediaInputs(this, usesAutogrow);
|
||||
if (usesAutogrow) {
|
||||
notifyAutogrowOfMigratedLink(this);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
},
|
||||
loadedGraphNode(node) {
|
||||
const usesAutogrow = Boolean(node.__llmSessionUsesMediaAutogrow);
|
||||
migrateLegacyMediaInputs(node, usesAutogrow);
|
||||
if (usesAutogrow) {
|
||||
notifyAutogrowOfMigratedLink(node);
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user