Reserve an action finalization pass when agent thinking returns no answer

This commit is contained in:
alisson-anjos
2026-10-19 10:59:47 +00:00
parent a501e3d345
commit 07e7a3f12e
6 changed files with 176 additions and 19 deletions
+3
View File
@@ -16,6 +16,9 @@ def inspect_vlm(clip, settings, checkpoint=None):
"agent_output_cap": 16384,
"requested_output_tokens": settings["max_tokens"],
"thinking_enabled": settings["thinking"],
"finalization_reserve_tokens": min(2048, settings["max_tokens"] // 4)
if settings["thinking"]
else 0,
"warnings": [],
"gpu": None,
"kv_estimate": None,
+48 -17
View File
@@ -310,34 +310,65 @@ class Runtime:
}
)
prompt += f"\nBUDGET: renders {s.renders}/{s.settings['max_renders']}. Images are in the order specified by the observation. Return one tool action JSON."
with GPU_LOCK:
output = p._textgenerate_execute(
budget = s.settings["max_tokens"]
thinking = s.settings["thinking"]
reserve = min(2048, budget // 4) if thinking else 0
images = visual_batch(s.images)
def generate(text, limit, use_thinking):
return p._textgenerate_execute(
TextGenerate,
clip=clip,
prompt=prompt,
max_length=s.settings["max_tokens"],
prompt=text,
max_length=limit,
sampling_mode={
"sampling_mode": "on",
"temperature": 0.6,
"temperature": 0.6 if use_thinking else 0.2,
"top_k": 20,
"top_p": 0.95,
"min_p": 0.0,
"seed": len(s.events),
},
thinking=s.settings["thinking"],
image=visual_batch(s.images),
thinking=use_thinking,
image=images,
)
text = str(output.args[0] or "").strip()
if not text:
reasoning_only = len(output.args) > 1 and bool(output.args[1])
raise ValueError(
"VLM returned no final action"
+ (
" (thinking output only; the token budget may have been exhausted)"
if reasoning_only
else " (empty response)"
with GPU_LOCK:
output = generate(prompt, budget - reserve, thinking)
text = str(output.args[0] or "").strip()
if not text and thinking:
if s.cancel.is_set() or s.status != "running":
return "" # tick checks cancellation before parsing
s.event(
"decision",
summary=(
"Thinking returned no final action. Requesting action JSON with Thinking off "
f"using the reserved {reserve} tokens; Thinking remains enabled for future decisions."
),
phase="finalize_action",
token_budget=reserve,
)
+ ". Increase Max tokens or disable Thinking, then Resume."
# Do not persist, display or feed truncated private reasoning back as instructions.
# Reuse the original task, observed state and images, with an explicit final-action request.
output = generate(
prompt
+ "\nFINAL ACTION REQUIRED: The previous attempt returned no final action. "
"Return exactly one registered tool action JSON now, for example "
'{"tool":"inspect_source","arguments":{"shot":0},"summary":"Inspect source and reference"}. '
"Choose the next useful action from the actual state above. Do not repeat the example blindly. "
"No prose, reasoning or Markdown. Do not claim a render happened unless the state confirms it.",
reserve,
False,
)
text = str(output.args[0] or "").strip()
if not text:
raise ValueError(
"VLM returned no final action even with Thinking off during finalization. "
"Check the checkpoint/tokenizer compatibility or disable Thinking and Resume."
)
if not text:
raise ValueError(
"VLM returned no final action with Thinking off. Check the checkpoint/tokenizer compatibility."
)
return text
+2 -1
View File
@@ -296,9 +296,10 @@ class Session:
)
return self.public()
try:
action = parse_action(decide(self))
answer = decide(self)
if self.cancel.is_set() or self.status != "running":
return self.public()
action = parse_action(answer)
self.event("action", **action, tab=TABS.get(action["tool"], "agent"))
if action["tool"] == "render":
if self.renders >= self.settings["max_renders"]:
+12
View File
@@ -141,3 +141,15 @@ The diagnostic does not load/offload models, allocate probe tensors or change se
Numeric settings update during typing so periodic session polls do not overwrite an edit
before the field loses focus.
## Thinking that never produces a final action
With Thinking enabled, each decision reserves up to one quarter of its output-token budget
(capped at 2,048 tokens) for finalization. If the first call returns no final action, the Agent
logs a `finalize_action` event and requests one action with Thinking off, using the original
task, observations and images. It does not feed back or persist truncated private reasoning.
The combined generation limits of both calls stay inside the configured decision budget:
8,192 tokens means at most 6,144 for the first call and 2,048 for finalization.
A valid first response needs no second call. The Thinking setting stays enabled for future
decisions. Pause/Stop between calls skips finalization; tool validation and render budgets
still apply. If finalization also fails, the Agent reports an error rather than inventing a tool action.
+110 -1
View File
@@ -76,10 +76,119 @@ class SessionTests(unittest.TestCase):
)
session = self.session()
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
with self.assertRaisesRegex(ValueError, "thinking output only") as error:
with self.assertRaisesRegex(
ValueError, "Thinking off during finalization"
) as error:
runtime.Runtime(planner).decide(session)
self.assertNotIn("private reasoning", str(error.exception))
def test_thinking_finalization_respects_total_budget_and_hides_reasoning(self):
fake = types.ModuleType("comfy_extras.nodes_textgen")
fake.TextGenerate = object()
calls = []
final = action("inspect_source", shot=0)
def generate(*args, **kwargs):
calls.append(kwargs)
return (
types.SimpleNamespace(args=("", "private reasoning"))
if len(calls) == 1
else types.SimpleNamespace(args=(final, ""))
)
planner = types.SimpleNamespace(
_VLM={"clip": object()}, _textgenerate_execute=generate
)
session = self.session(max_tokens=8192)
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
result = runtime.Runtime(planner).decide(session)
self.assertEqual(core.parse_action(result)["tool"], "inspect_source")
self.assertEqual([c["thinking"] for c in calls], [True, False])
self.assertEqual([c["max_length"] for c in calls], [6144, 2048])
self.assertTrue(session.settings["thinking"])
self.assertNotIn("private reasoning", calls[1]["prompt"])
self.assertNotIn("private reasoning", json.dumps(session.public()))
self.assertEqual(session.events[-1]["phase"], "finalize_action")
def test_valid_thinking_action_needs_no_finalization(self):
fake = types.ModuleType("comfy_extras.nodes_textgen")
fake.TextGenerate = object()
from unittest.mock import Mock
generate = Mock(
return_value=types.SimpleNamespace(args=(action("inspect_plan"), "private"))
)
planner = types.SimpleNamespace(
_VLM={"clip": object()}, _textgenerate_execute=generate
)
session = self.session()
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
runtime.Runtime(planner).decide(session)
self.assertEqual(generate.call_count, 1)
self.assertEqual(session.events, [])
def test_no_second_call_when_thinking_is_disabled(self):
fake = types.ModuleType("comfy_extras.nodes_textgen")
fake.TextGenerate = object()
from unittest.mock import Mock
generate = Mock(return_value=types.SimpleNamespace(args=("", "")))
planner = types.SimpleNamespace(
_VLM={"clip": object()}, _textgenerate_execute=generate
)
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
with self.assertRaisesRegex(ValueError, "with Thinking off"):
runtime.Runtime(planner).decide(self.session(thinking=False))
self.assertEqual(generate.call_count, 1)
self.assertEqual(generate.call_args.kwargs["max_length"], 4096)
def test_pause_after_thinking_skips_finalization_and_parsing(self):
fake = types.ModuleType("comfy_extras.nodes_textgen")
fake.TextGenerate = object()
session = self.session()
calls = []
def generate(*args, **kwargs):
calls.append(kwargs)
session.cancel.set()
session.status = "paused"
return types.SimpleNamespace(args=("", "private"))
planner = types.SimpleNamespace(
_VLM={"clip": object()}, _textgenerate_execute=generate
)
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
session.tick(
runtime.Runtime(planner).decide,
lambda *_: self.fail("must not execute"),
)
self.assertEqual(len(calls), 1)
self.assertEqual(session.error_count, 0)
self.assertEqual(session.events, [])
def test_finalization_does_not_bypass_registered_tool_validation(self):
fake = types.ModuleType("comfy_extras.nodes_textgen")
fake.TextGenerate = object()
from unittest.mock import Mock
generate = Mock(
side_effect=[
types.SimpleNamespace(args=("", "private")),
types.SimpleNamespace(args=(action("shell"), "")),
]
)
planner = types.SimpleNamespace(
_VLM={"clip": object()}, _textgenerate_execute=generate
)
session = self.session()
with patch.dict(sys.modules, {"comfy_extras.nodes_textgen": fake}):
session.tick(
runtime.Runtime(planner).decide,
lambda *_: self.fail("must not execute"),
)
self.assertIn("registered tool", session.observation["error"])
self.assertEqual(session.renders, 0)
def test_plan_is_isolated(self):
p = {"segs": [{"prompt": "old"}]}
s = core.Session("task", p)
+1
View File
@@ -105,6 +105,7 @@ export function agentTab(c) {
h('div',`Checkpoint Thinking capability: ${a.vlmInfo.thinking_checkpoint||'unknown'}`),
h('div',`Backend context: ${a.vlmInfo.context_tokens??'unknown'} tokens · Agent output cap: ${a.vlmInfo.agent_output_cap??'unknown'} · Requested output: ${a.vlmInfo.requested_output_tokens??'unknown'}`),
a.vlmInfo.context_source?h('div',{class:'hint'},a.vlmInfo.context_source):null,
a.vlmInfo.finalization_reserve_tokens?h('div',{class:'hint'},`Thinking finalization reserve: ${a.vlmInfo.finalization_reserve_tokens} tokens within the decision budget; used only if the first call returns no final action.`):null,
a.vlmInfo.gpu?h('div',`${a.vlmInfo.gpu.name}: ${a.vlmInfo.gpu.free_gib}/${a.vlmInfo.gpu.total_gib} GiB free · VLM weights not resident: ${a.vlmInfo.gpu.vlm_weights_not_resident_gib} GiB · Reserve: ${a.vlmInfo.gpu.reserve_gib} GiB`):null,
a.vlmInfo.kv_estimate?h('div',[
h('div',`Estimated total KV capacity: ${a.vlmInfo.kv_estimate.total_tokens} tokens · Requested output KV: ${a.vlmInfo.kv_estimate.requested_output_cache_mib} MiB`),