@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.
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-23T08:
|
|
3
|
-
"checkpoint_at": "2026-03-23T08:
|
|
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
|
@@ -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
|
-
|
|
1773
|
-
|
|
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=
|
|
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
|
|
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
|