From d054f9aee11e8e2daf51dcc28175cbab6253e803 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Fri, 27 Mar 2026 20:07:32 +0900 Subject: [PATCH] feat: implement automatic payload candidate retry mechanism in AIPlanner for improved LM Studio compatibility --- README.md | 2 +- ai_planner.py | 67 +++++++++++++++++++++--- docs/plan.md | 17 ++++++ tests/test_ai_planner_lmstudio_compat.py | 20 +++++-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0352826..d85506e 100644 --- a/README.md +++ b/README.md @@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다. diff --git a/ai_planner.py b/ai_planner.py index 56a01e8..3a9af42 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -196,14 +196,31 @@ class AIPlanner: spinner.start() try: - payload = self._build_chat_payload(user_message) - resp = httpx.post( - f"{OLLAMA_HOST}/api/v1/chat", - json=payload, - timeout=600.0, - ) - resp.raise_for_status() - data = resp.json() + 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, + ) + 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") diff --git a/docs/plan.md b/docs/plan.md index 13f796b..07d21e1 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -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 반복 설정 제거 (우클릭 유지) ### 문제 diff --git a/tests/test_ai_planner_lmstudio_compat.py b/tests/test_ai_planner_lmstudio_compat.py index 624f20e..bd236a0 100644 --- a/tests/test_ai_planner_lmstudio_compat.py +++ b/tests/test_ai_planner_lmstudio_compat.py @@ -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": {