Drop max_tokens cap on codegen; qwen3 reasoning truncation left content empty
qwen38-iq3s reasons extensively before emitting the skill body. A max_tokens cap truncated the hidden reasoning (finish_reason: length) leaving content empty, so the body write failed with 'skill body is empty'. Omit max_tokens so the model runs to completion (~7 min); reasoning is filtered automatically since only content is read. Client timeout default raised to 1200s.
This commit is contained in:
@@ -185,9 +185,16 @@ unit tests (24) + box integration tests (2).
|
|||||||
### codegen (skill bodies, box)
|
### codegen (skill bodies, box)
|
||||||
- Skill **bodies** are written by a separate OpenAI-compatible model, configured
|
- Skill **bodies** are written by a separate OpenAI-compatible model, configured
|
||||||
under `codegen` in config.json (default model `qwen38-iq3s`, the 12G 27B
|
under `codegen` in config.json (default model `qwen38-iq3s`, the 12G 27B
|
||||||
IQ3_S GGUF — huge/slow; a 3-bit 27B write can take 30-120s). Title +
|
IQ3_S GGUF — huge/slow). Title + description for new skills still come from
|
||||||
description for new skills still come from the **small** decision model
|
the **small** decision model (`engine.generate`); only the runnable code body
|
||||||
(`engine.generate`); only the runnable code body uses codegen.
|
uses codegen.
|
||||||
|
- **Do NOT cap `max_tokens`** on the codegen call. qwen38-iq3s reasons first
|
||||||
|
and a cap truncates the hidden reasoning, leaving `content` empty
|
||||||
|
(`finish_reason: length`) and the body write fails with "skill body is
|
||||||
|
empty". Unbounded, it runs to completion in ~7 min (~40k chars of reasoning
|
||||||
|
then the code); the client reads only `content`, so reasoning is filtered
|
||||||
|
automatically. The client default timeout is 1200s — raise `codegen.timeout`
|
||||||
|
in config if a harder prompt needs more.
|
||||||
- Bodies are persisted to `data/skills/<category>/<name>.py` (gitignored) and
|
- Bodies are persisted to `data/skills/<category>/<name>.py` (gitignored) and
|
||||||
loaded back at startup via `importlib`, so skills stay runnable across
|
loaded back at startup via `importlib`, so skills stay runnable across
|
||||||
restarts. `SKILL.md` at the repo root is the contract the codegen model is
|
restarts. `SKILL.md` at the repo root is the contract the codegen model is
|
||||||
|
|||||||
+2
-2
@@ -13,10 +13,10 @@
|
|||||||
},
|
},
|
||||||
"llm": {"base_url": "http://localhost:11434/v1", "model": "qwen3.5:4b"},
|
"llm": {"base_url": "http://localhost:11434/v1", "model": "qwen3.5:4b"},
|
||||||
"codegen": {
|
"codegen": {
|
||||||
"_comment": "OpenAI-compatible model that writes runnable skill bodies. Larger/slower than the decision or self-assessment model.",
|
"_comment": "OpenAI-compatible model that writes runnable skill bodies. Larger/slower than the decision or self-assessment model. No max_tokens cap: qwen38-iq3s reasons extensively (~7 min) before emitting the body; reasoning is filtered automatically. timeout is seconds.",
|
||||||
"base_url": "http://localhost:11434/v1",
|
"base_url": "http://localhost:11434/v1",
|
||||||
"model": "qwen38-iq3s",
|
"model": "qwen38-iq3s",
|
||||||
"timeout": 600
|
"timeout": 1200
|
||||||
},
|
},
|
||||||
"skill_bodies": "data/skills",
|
"skill_bodies": "data/skills",
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|||||||
+23
-12
@@ -34,23 +34,34 @@ def read_skill_contract(path: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class CodegenClient:
|
class CodegenClient:
|
||||||
"""Minimal OpenAI-compatible chat client for writing skill bodies."""
|
"""Minimal OpenAI-compatible chat client for writing skill bodies.
|
||||||
|
|
||||||
def __init__(self, base_url: str, model: str, timeout: float = 600.0):
|
No token cap by default: Qwen3-style models reason first and the cap
|
||||||
|
truncates the hidden reasoning, leaving `content` empty. Omit `max_tokens`
|
||||||
|
so the model runs to completion; the reasoning is filtered automatically
|
||||||
|
because only `content` is read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, model: str, timeout: float = 1200.0):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.model = model
|
self.model = model
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def chat(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.0) -> str:
|
def chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
temperature: float = 0.0,
|
||||||
|
) -> str:
|
||||||
url = f"{self.base_url}/chat/completions"
|
url = f"{self.base_url}/chat/completions"
|
||||||
body = json.dumps(
|
payload: dict = {
|
||||||
{
|
"model": self.model,
|
||||||
"model": self.model,
|
"messages": messages,
|
||||||
"messages": messages,
|
"temperature": temperature,
|
||||||
"temperature": temperature,
|
}
|
||||||
"max_tokens": max_tokens,
|
if max_tokens is not None:
|
||||||
}
|
payload["max_tokens"] = max_tokens
|
||||||
).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url, data=body, headers={"Content-Type": "application/json"}
|
url, data=body, headers={"Content-Type": "application/json"}
|
||||||
)
|
)
|
||||||
@@ -145,7 +156,7 @@ def generate_skill_body(
|
|||||||
draft: SkillDraft,
|
draft: SkillDraft,
|
||||||
tree: dict,
|
tree: dict,
|
||||||
contract: str | None = None,
|
contract: str | None = None,
|
||||||
max_tokens: int = 2048,
|
max_tokens: int | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Author a skill body with the big model; retries once on invalid output."""
|
"""Author a skill body with the big model; retries once on invalid output."""
|
||||||
contract_text = contract if contract is not None else read_skill_contract()
|
contract_text = contract if contract is not None else read_skill_contract()
|
||||||
|
|||||||
+28
-2
@@ -163,10 +163,12 @@ def test_merge_skill_bodies_creates_missing_category(tmp_path):
|
|||||||
|
|
||||||
class _FakeOpenAI(BaseHTTPRequestHandler):
|
class _FakeOpenAI(BaseHTTPRequestHandler):
|
||||||
reply: str = GOOD_BODY
|
reply: str = GOOD_BODY
|
||||||
|
received: list = []
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
self.rfile.read(length)
|
raw = self.rfile.read(length).decode("utf-8")
|
||||||
|
type(self).received.append(json.loads(raw))
|
||||||
body = json.dumps({"choices": [{"message": {"content": self.reply}}]}).encode("utf-8")
|
body = json.dumps({"choices": [{"message": {"content": self.reply}}]}).encode("utf-8")
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
@@ -179,7 +181,7 @@ class _FakeOpenAI(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
def _fake_server(reply: str) -> tuple[ThreadingHTTPServer, str]:
|
def _fake_server(reply: str) -> tuple[ThreadingHTTPServer, str]:
|
||||||
handler = type("Handler", (_FakeOpenAI,), {"reply": reply})
|
handler = type("Handler", (_FakeOpenAI,), {"reply": reply, "received": []})
|
||||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
return httpd, f"http://127.0.0.1:{httpd.server_address[1]}/v1"
|
return httpd, f"http://127.0.0.1:{httpd.server_address[1]}/v1"
|
||||||
@@ -217,3 +219,27 @@ def test_codegen_client_unreachable_raises(tmp_path):
|
|||||||
draft = SkillDraft(name="probe", description="Probe the service.")
|
draft = SkillDraft(name="probe", description="Probe the service.")
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
generate_skill_body(client, Request("is the service up?"), "tracking", draft, tree)
|
generate_skill_body(client, Request("is the service up?"), "tracking", draft, tree)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_omits_max_tokens_by_default():
|
||||||
|
httpd, base = _fake_server(GOOD_BODY)
|
||||||
|
try:
|
||||||
|
client = CodegenClient(base_url=base, model="test", timeout=10)
|
||||||
|
client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
body = httpd.RequestHandlerClass.received[0]
|
||||||
|
assert "max_tokens" not in body
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_includes_max_tokens_when_set():
|
||||||
|
httpd, base = _fake_server(GOOD_BODY)
|
||||||
|
try:
|
||||||
|
client = CodegenClient(base_url=base, model="test", timeout=10)
|
||||||
|
client.chat([{"role": "user", "content": "hi"}], max_tokens=512)
|
||||||
|
body = httpd.RequestHandlerClass.received[0]
|
||||||
|
assert body["max_tokens"] == 512
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
Reference in New Issue
Block a user