@misterhuydo/sentinel 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-23T08:28:23.743Z",
3
- "checkpoint_at": "2026-03-23T08:28:23.744Z",
2
+ "message": "Auto-checkpoint at 2026-03-23T08:30:55.526Z",
3
+ "checkpoint_at": "2026-03-23T08:30:55.527Z",
4
4
  "active_files": [],
5
5
  "notes": [],
6
6
  "mtime_snapshot": {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1722,6 +1722,80 @@ async def _handle_with_cli(
1722
1722
  return reply, is_done
1723
1723
 
1724
1724
 
1725
+ # ── History serialization helpers ────────────────────────────────────────────
1726
+
1727
+ def _serialize_content(content) -> list:
1728
+ """Convert Anthropic SDK response content (Pydantic objects) to plain dicts.
1729
+
1730
+ The SDK returns TextBlock / ToolUseBlock instances. json.dumps(..., default=str)
1731
+ turns them into useless strings like "TextBlock(type='text', text='...')".
1732
+ This converts them to proper dicts so history round-trips through SQLite safely.
1733
+ """
1734
+ if not isinstance(content, list):
1735
+ return content
1736
+ result = []
1737
+ for block in content:
1738
+ if isinstance(block, dict):
1739
+ result.append(block)
1740
+ elif hasattr(block, "model_dump"):
1741
+ result.append(block.model_dump())
1742
+ elif hasattr(block, "dict"):
1743
+ result.append(block.dict())
1744
+ elif hasattr(block, "type"):
1745
+ if block.type == "text":
1746
+ result.append({"type": "text", "text": getattr(block, "text", "")})
1747
+ elif block.type == "tool_use":
1748
+ result.append({
1749
+ "type": "tool_use",
1750
+ "id": getattr(block, "id", ""),
1751
+ "name": getattr(block, "name", ""),
1752
+ "input": getattr(block, "input", {}),
1753
+ })
1754
+ else:
1755
+ result.append({"type": "text", "text": str(block)})
1756
+ return result
1757
+
1758
+
1759
+ def _clean_history(history: list) -> list:
1760
+ """Remove turns that would cause a 400 from the Anthropic API.
1761
+
1762
+ Strips orphaned tool_use blocks (assistant turn with tool_use but no
1763
+ following tool_result turn) and consecutive same-role turns that result
1764
+ from a previous session that crashed mid-tool-loop.
1765
+ """
1766
+ cleaned = []
1767
+ i = 0
1768
+ while i < len(history):
1769
+ turn = history[i]
1770
+ role = turn.get("role", "")
1771
+ content = turn.get("content", [])
1772
+
1773
+ # Drop assistant turns that contain tool_use if the next turn isn't tool_result
1774
+ if role == "assistant" and isinstance(content, list):
1775
+ has_tool_use = any(
1776
+ (isinstance(b, dict) and b.get("type") == "tool_use")
1777
+ for b in content
1778
+ )
1779
+ if has_tool_use:
1780
+ next_turn = history[i + 1] if i + 1 < len(history) else None
1781
+ next_content = (next_turn or {}).get("content", [])
1782
+ has_result = isinstance(next_content, list) and any(
1783
+ (isinstance(b, dict) and b.get("type") == "tool_result")
1784
+ for b in next_content
1785
+ )
1786
+ if not has_result:
1787
+ i += 1 # skip orphaned tool_use turn
1788
+ continue
1789
+
1790
+ # Drop consecutive same-role turns (keep the last one)
1791
+ if cleaned and cleaned[-1].get("role") == role:
1792
+ cleaned[-1] = turn
1793
+ else:
1794
+ cleaned.append(turn)
1795
+ i += 1
1796
+ return cleaned
1797
+
1798
+
1725
1799
  # ── API-key path (structured tools, full agentic loop) ────────────────────────
1726
1800
 
1727
1801
  async def _handle_with_api(
@@ -1769,13 +1843,15 @@ async def _handle_with_api(
1769
1843
  user_content = attach_blocks + [{"type": "text", "text": message}]
1770
1844
  else:
1771
1845
  user_content = message
1772
- history.append({"role": "user", "content": user_content})
1773
- messages = list(history)
1846
+
1847
+ # Work on a local copy — only commit to history on success to prevent
1848
+ # cascading 400s if the API rejects a malformed/corrupted history.
1849
+ messages = list(history) + [{"role": "user", "content": user_content}]
1774
1850
 
1775
1851
  while True:
1776
1852
  response = client.messages.create(
1777
1853
  model="claude-opus-4-6",
1778
- max_tokens=1024,
1854
+ max_tokens=2048,
1779
1855
  system=system,
1780
1856
  tools=_TOOLS,
1781
1857
  messages=messages,
@@ -1799,10 +1875,12 @@ async def _handle_with_api(
1799
1875
  # Heuristic override: if reply ends with a question, Claude is waiting for input
1800
1876
  if is_done and re.search(r'\?\s*$', reply):
1801
1877
  is_done = False
1802
- history.append({"role": "assistant", "content": response.content})
1878
+ # Commit to history only on success — serialize SDK objects to plain dicts
1879
+ history.append({"role": "user", "content": user_content})
1880
+ history.append({"role": "assistant", "content": _serialize_content(response.content)})
1803
1881
  return reply, is_done
1804
1882
 
1805
- messages.append({"role": "assistant", "content": response.content})
1883
+ messages.append({"role": "assistant", "content": _serialize_content(response.content)})
1806
1884
  tool_results = []
1807
1885
  for tc in tool_blocks:
1808
1886
  result = await _run_tool(tc.name, tc.input, cfg_loader, store, slack_client=slack_client, user_id=user_id, channel=channel, is_admin=is_admin)
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
23
23
  from pathlib import Path
24
24
  from typing import Optional
25
25
 
26
- from .sentinel_boss import handle_message
26
+ from .sentinel_boss import handle_message, _clean_history
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
@@ -370,9 +370,10 @@ _MAX_HISTORY_TURNS = 20 # keep last 20 exchanges (~40 messages) to stay well w
370
370
  async def _run_turn(session: _Session, message: str, client, cfg_loader, store, attachments: list | None = None, is_admin: bool = False) -> None:
371
371
  channel = session.channel
372
372
 
373
- # Load persisted history from DB on the first turn of a new session
373
+ # Load persisted history from DB on the first turn of a new session.
374
+ # Clean it to strip any orphaned tool_use turns from a previous crashed session.
374
375
  if not session.history_loaded:
375
- session.history = store.load_conversation(session.user_id)
376
+ session.history = _clean_history(store.load_conversation(session.user_id))
376
377
  session.history_loaded = True
377
378
 
378
379
  # Trim history to avoid context overflow on long conversations