@misterhuydo/sentinel 1.1.1 → 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.1",
3
+ "version": "1.1.3",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -177,7 +177,8 @@ When to act vs. when to ask:
177
177
  - Clear command ("check status", "fetch logs", "pause sentinel") → call the tool immediately, reply with results.
178
178
  - Ambiguous or exploratory ("what does get_repo_status do?", "tell me about search_logs") → explain the tool naturally, then ask: "Want me to run it?"
179
179
  - Unclear intent (could be either) → use judgment: brief explanation + "Want me to run this now?"
180
- Never say "Stand by" or "Requesting..." and then return nothing. Either act, or ask.
180
+ - If a tool call will take a moment (search, fetch, pull), prefix your reply with a brief "working" line ending in "..." before the results, e.g. "Searching SSOLWA for TryDig activity..." then the actual output.
181
+ Never just say a working line and stop — always follow it with the results in the same message.
181
182
 
182
183
  Issue identification — before calling create_issue:
183
184
  1. Determine if the message is a REAL issue/task (bug report, feature request, investigation ask)
@@ -1721,6 +1722,80 @@ async def _handle_with_cli(
1721
1722
  return reply, is_done
1722
1723
 
1723
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
+
1724
1799
  # ── API-key path (structured tools, full agentic loop) ────────────────────────
1725
1800
 
1726
1801
  async def _handle_with_api(
@@ -1768,13 +1843,15 @@ async def _handle_with_api(
1768
1843
  user_content = attach_blocks + [{"type": "text", "text": message}]
1769
1844
  else:
1770
1845
  user_content = message
1771
- history.append({"role": "user", "content": user_content})
1772
- 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}]
1773
1850
 
1774
1851
  while True:
1775
1852
  response = client.messages.create(
1776
1853
  model="claude-opus-4-6",
1777
- max_tokens=1024,
1854
+ max_tokens=2048,
1778
1855
  system=system,
1779
1856
  tools=_TOOLS,
1780
1857
  messages=messages,
@@ -1798,10 +1875,12 @@ async def _handle_with_api(
1798
1875
  # Heuristic override: if reply ends with a question, Claude is waiting for input
1799
1876
  if is_done and re.search(r'\?\s*$', reply):
1800
1877
  is_done = False
1801
- 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)})
1802
1881
  return reply, is_done
1803
1882
 
1804
- messages.append({"role": "assistant", "content": response.content})
1883
+ messages.append({"role": "assistant", "content": _serialize_content(response.content)})
1805
1884
  tool_results = []
1806
1885
  for tc in tool_blocks:
1807
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