diff --git a/CHANGELOG.md b/CHANGELOG.md index 7815a6f..8c57894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.7] - 2026-06-15 + +### Fixed + +- 🛡️ **Chat no longer breaks after crashes or interruptions.** If a previous session ended unexpectedly, leftover data could cause permanent errors when resuming a conversation. The chat now automatically cleans up mismatched data on load so you can always pick up where you left off. +- 🔄 **Better compatibility with OpenAI models.** Fixed issues where certain internal data was accidentally sent to OpenAI, causing requests to fail. Conversations with tool use now work reliably across all supported providers. +- 🪵 **Clearer error reporting for AI requests.** When an AI request fails, the error details are now logged properly instead of being silently swallowed, making issues easier to diagnose. + ## [0.4.6] - 2026-06-15 ### Added diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index 769a20a..08b8156 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -343,14 +343,18 @@ async def stream_anthropic( def _to_openai_messages(messages: list[dict], instructions: str) -> list[dict]: - """Canonical messages → OpenAI Chat Completions format.""" + """Canonical messages → OpenAI Chat Completions format. + + Strips non-standard fields (reasoning_items, fc_id in tool_calls) + that are used internally but would cause 400 errors from OpenAI. + """ result = [] if instructions: result.append({"role": "system", "content": instructions}) for m in messages: if m["role"] == "system": continue - + content = m.get("content", "") if isinstance(content, list): formatted_content = [] @@ -368,9 +372,20 @@ def _to_openai_messages(messages: list[dict], instructions: str) -> list[dict]: }) new_m = dict(m) new_m["content"] = formatted_content + # Strip non-standard fields + new_m.pop("reasoning_items", None) result.append(new_m) else: - result.append(m) + out = dict(m) + # Strip non-standard fields that are only for Responses API + out.pop("reasoning_items", None) + # Clean tool_calls: remove fc_id which is Responses-API-only + if "tool_calls" in out: + out["tool_calls"] = [ + {k: v for k, v in tc.items() if k != "fc_id"} + for tc in out["tool_calls"] + ] + result.append(out) return result @@ -414,6 +429,9 @@ async def stream_openai_completions( "POST", f"{url}/chat/completions", json=body, headers=headers ) as resp: logger.info("[stream] openai completions status=%s", resp.status_code) + if resp.status_code >= 400: + error_body = await resp.aread() + logger.error("[stream] openai completions error body: %s", error_body.decode(errors="replace")) resp.raise_for_status() tool_calls: dict[int, dict] = {} async for line in resp.aiter_lines(): diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index c691737..f511f34 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -669,18 +669,26 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic pass else: for ti, turn in enumerate(turns): - if turn["calls"]: + # Filter calls to only those with matching outputs + turn_output_ids = {o["call_id"] for o in turn["outputs"]} + matched_calls = [ + tc for tc in turn["calls"] + if tc["id"] in turn_output_ids + ] + if matched_calls: if ti == 0: # First turn: attach to the existing entry - entry["tool_calls"] = turn["calls"] + entry["tool_calls"] = matched_calls if turn["reasoning"]: entry["reasoning_items"] = turn["reasoning"] else: - # Subsequent turns: create a new assistant entry + # Subsequent turns: flush the pending entry (last tool + # result from previous turn) before creating a new one. + result.append(entry) entry = { "role": "assistant", "content": "", - "tool_calls": turn["calls"], + "tool_calls": matched_calls, } if turn["reasoning"]: entry["reasoning_items"] = turn["reasoning"] @@ -693,9 +701,76 @@ async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dic } result.append(entry) + + # ── Final sanitization: ensure every tool_call has a matching tool result ── + # This catches edge cases from compaction, DB corruption, or partial persistence. + result = _sanitize_tool_pairs(result) + return result, existing_summary +def _sanitize_tool_pairs(messages: list[dict]) -> list[dict]: + """Ensure every tool_call in an assistant message has a matching tool result. + + Walks the message list and collects all tool result call_ids. Then + strips any tool_call entries from assistant messages that have no + matching result. Also removes orphaned tool-result messages. + + This is the last line of defence against 400 errors from providers + that require strict tool_call ↔ tool_result pairing (OpenAI). + """ + # Collect all tool-result call_ids in the conversation + tool_result_ids = { + m["tool_call_id"] + for m in messages + if m.get("role") == "tool" and m.get("tool_call_id") + } + + # Collect all tool_call ids declared by assistant messages + tool_call_ids = set() + for m in messages: + if m.get("role") == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + tool_call_ids.add(tc.get("id", "")) + + sanitized = [] + for m in messages: + if m.get("role") == "assistant" and m.get("tool_calls"): + # Keep only tool_calls that have a matching tool result + kept = [tc for tc in m["tool_calls"] if tc.get("id") in tool_result_ids] + dropped = len(m["tool_calls"]) - len(kept) + if dropped: + logger.warning( + "[sanitize] Dropped %d orphaned tool_call(s) from assistant message", + dropped, + ) + if kept: + m = dict(m) + m["tool_calls"] = kept + sanitized.append(m) + else: + # No tool_calls left — keep as plain text if there's content + m = dict(m) + del m["tool_calls"] + m.pop("reasoning_items", None) + if m.get("content"): + sanitized.append(m) + # else: drop empty assistant message entirely + elif m.get("role") == "tool": + # Drop tool results that have no matching tool_call + if m.get("tool_call_id") not in tool_call_ids: + logger.warning( + "[sanitize] Dropped orphaned tool result for call_id=%s", + m.get("tool_call_id", "?"), + ) + continue + sanitized.append(m) + else: + sanitized.append(m) + + return sanitized + + def _parse_image_data_uri(result: str) -> tuple[str, str] | None: """Check if a tool result is a data URI image (from read_file on image files). diff --git a/pyproject.toml b/pyproject.toml index 825f315..1c4ead7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.4.6" +version = "0.4.7" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"