feat: implement automatic payload candidate retry mechanism in AIPlanner for improved LM Studio compatibility
This commit is contained in:
@@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||
|
||||
@@ -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")
|
||||
|
||||
17
docs/plan.md
17
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 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user