feat: implement automatic payload candidate retry mechanism in AIPlanner for improved LM Studio compatibility

This commit is contained in:
21in7
2026-03-27 20:07:32 +09:00
parent 2cf072d38c
commit d054f9aee1
4 changed files with 94 additions and 12 deletions

View File

@@ -142,7 +142,7 @@ planner.set_goal(
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
- `ai_planner.py`의 Ollama/LM Studio 호출 payload는 `messages`와 `input`을 함께 전송해, 서버가 `input` 필드를 요구하는 OpenAI Responses 호환 모드에서도 400(`'input' is required`)을 피하도록 했습니다.
- `ai_planner.py`의 Ollama/LM Studio 호출은 서버 스키마 차이를 자동으로 맞추기 위해 payload 후보를 순차 재시도합니다. 먼저 `messages` 기반(legacy chat)으로 시도하고, 400(`unrecognized_keys`/`input required`)이면 `input` 중심 최소 payload로 재시도합니다.
- `ai_planner.py`는 LLM이 move 순서를 놓쳐 `place_entity`/`insert_to_entity`/`set_recipe`가 건설 거리 제한으로 실패하는 경우를 줄이기 위해, 해당 액션 직전에 최근 `move`가 같은 좌표가 아니면 자동으로 `move`를 끼워 넣습니다
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.

View File

@@ -196,14 +196,31 @@ class AIPlanner:
spinner.start()
try:
payload = self._build_chat_payload(user_message)
data = None
last_http_error: Exception | None = None
candidates = self._build_payload_candidates(user_message)
for i, payload in enumerate(candidates):
resp = httpx.post(
f"{OLLAMA_HOST}/api/v1/chat",
json=payload,
timeout=600.0,
)
resp.raise_for_status()
if resp.status_code < 400:
data = resp.json()
break
if resp.status_code == 400 and i < len(candidates) - 1:
err_msg = self._extract_http_error_message(resp)
print(f"[AI] payload 호환 재시도 {i + 1}: {err_msg}")
continue
try:
resp.raise_for_status()
except Exception as e:
last_http_error = e
break
if data is None:
if last_http_error is not None:
raise last_http_error
raise RuntimeError("Ollama/LM Studio 응답을 받지 못했습니다.")
finally:
stop_event.set()
spinner.join()
@@ -232,6 +249,40 @@ class AIPlanner:
"options": {"temperature": 0.3, "num_ctx": 8192},
}
@staticmethod
def _build_input_only_payload(user_message: str) -> dict:
merged = f"{SYSTEM_PROMPT}\n\n{user_message}"
return {
"model": OLLAMA_MODEL,
"input": merged,
"stream": False,
}
@staticmethod
def _build_payload_candidates(user_message: str) -> list[dict]:
# 서버 구현체마다 스키마가 달라 자동 호환을 위해 후보를 순차 시도한다.
return [
AIPlanner._build_chat_payload(user_message),
AIPlanner._build_input_only_payload(user_message),
]
@staticmethod
def _extract_http_error_message(resp: httpx.Response) -> str:
try:
data = resp.json()
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
msg = err.get("message")
if isinstance(msg, str) and msg.strip():
return msg
except Exception:
pass
text = resp.text.strip()
if text:
return text[:200]
return f"HTTP {resp.status_code}"
@staticmethod
def _extract_response_content(data: dict) -> str:
message = data.get("message")

View File

@@ -16,6 +16,23 @@
---
## 2026-03-27 LM Studio `unrecognized_keys` 400 추가 수정 계획
### 문제 요약
- `input`을 추가한 뒤에도 LM Studio 서버가 `messages`, `format`, `think`, `options` 키를 거부하며 400을 반환.
- 즉, 서버 모드에 따라 허용 스키마가 다르며 단일 payload로는 호환이 불안정.
### 구현 계획
1. `ai_planner.py`에서 payload를 단일 형태로 고정하지 않고, `legacy chat``input-only` 후보를 순차 시도한다.
2. 400 응답일 때 에러 메시지를 파싱해 다음 후보 payload로 자동 재시도한다.
3. README 설명을 “messages+input 동시 전송”에서 “서버 스키마 자동 호환 재시도”로 정정한다.
### 검증 계획
- `python -m py_compile ai_planner.py`
- LM Studio 로그에서 `unrecognized_keys`가 사라지고 정상 응답으로 전환되는지 확인
---
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
### 문제

View File

@@ -4,14 +4,28 @@ from ai_planner import AIPlanner
class TestAIPlannerLMStudioCompat(unittest.TestCase):
def test_build_chat_payload_includes_input_for_responses_compat(self):
def test_build_chat_payload_has_legacy_chat_keys(self):
payload = AIPlanner._build_chat_payload("hello")
self.assertIn("messages", payload)
self.assertIn("input", payload)
self.assertEqual(payload["input"], "hello")
self.assertIn("options", payload)
self.assertEqual(payload["messages"][1]["content"], "hello")
def test_build_input_only_payload_has_minimal_keys(self):
payload = AIPlanner._build_input_only_payload("hello")
self.assertIn("input", payload)
self.assertNotIn("messages", payload)
self.assertEqual(payload["stream"], False)
self.assertIn("hello", payload["input"])
def test_payload_candidates_order(self):
payloads = AIPlanner._build_payload_candidates("hello")
self.assertEqual(len(payloads), 2)
self.assertIn("messages", payloads[0])
self.assertIn("input", payloads[1])
self.assertNotIn("messages", payloads[1])
def test_extract_response_content_supports_message_content(self):
data = {
"message": {