mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c41f0a59 | |||
| d107e6edf0 | |||
| 22c20a8dbd | |||
| 179143431d | |||
| d4a6866045 | |||
| c4abd62226 | |||
| b3926c372a | |||
| 5efe52d433 |
@@ -74,6 +74,53 @@ jobs:
|
||||
env:
|
||||
TURNSTONE_TEST_PG_URL: postgresql+psycopg://postgres:postgres@localhost:5432/turnstone_test
|
||||
|
||||
wheel-completeness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- run: pip install build
|
||||
- run: python -m build --wheel
|
||||
- name: Check all data files are in wheel
|
||||
run: |
|
||||
SOURCE=$(find turnstone -type f \
|
||||
! -name '*.py' ! -name '*.pyc' ! -path '*__pycache__*' \
|
||||
| sort)
|
||||
WHEEL=$(python -m zipfile -l dist/*.whl \
|
||||
| awk '{print $1}' \
|
||||
| grep -v '\.py$' | grep -v '\.dist-info' | grep -v '\.pyc' | grep -v '^File$' \
|
||||
| sort)
|
||||
|
||||
# Files intentionally excluded from the wheel (one per line)
|
||||
ALLOW="
|
||||
turnstone/core/storage/migrations/script.py.mako
|
||||
"
|
||||
|
||||
MISSING=$(comm -23 <(echo "$SOURCE") <(echo "$WHEEL") \
|
||||
| grep -vFxf <(echo "$ALLOW" | sed '/^[[:space:]]*$/d; s/^[[:space:]]*//' ) || true)
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::Data files in source tree but missing from wheel:"
|
||||
echo "$MISSING"
|
||||
echo ""
|
||||
echo "Add them to [tool.hatch.build.targets.wheel] in pyproject.toml"
|
||||
echo "or to the ALLOW list in this job if intentionally excluded."
|
||||
exit 1
|
||||
fi
|
||||
echo "All source data files present in wheel"
|
||||
- name: Smoke-test entry points from installed wheel
|
||||
run: |
|
||||
python -m venv /tmp/smoke
|
||||
/tmp/smoke/bin/pip install dist/*.whl
|
||||
/tmp/smoke/bin/turnstone --help
|
||||
/tmp/smoke/bin/turnstone-server --help
|
||||
/tmp/smoke/bin/turnstone-console --help
|
||||
/tmp/smoke/bin/turnstone-admin --help
|
||||
/tmp/smoke/bin/turnstone-channel --help
|
||||
/tmp/smoke/bin/turnstone-bootstrap --help
|
||||
|
||||
lock-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
+3
-6
@@ -60,9 +60,7 @@ services:
|
||||
# turnstone-server — Web UI + chat workstreams + LLM interaction
|
||||
# -------------------------------------------------------------------
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
command:
|
||||
@@ -115,6 +113,7 @@ services:
|
||||
# turnstone-console — Cluster dashboard
|
||||
# -------------------------------------------------------------------
|
||||
console:
|
||||
image: turnstone:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -145,9 +144,7 @@ services:
|
||||
# Requires TURNSTONE_DISCORD_TOKEN to enable Discord adapter
|
||||
# -------------------------------------------------------------------
|
||||
channel:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: turnstone:local
|
||||
profiles:
|
||||
- production
|
||||
- cluster
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "1.0.0"
|
||||
version = "1.0.2"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
@@ -67,6 +67,7 @@ turnstone-bootstrap = "turnstone.bootstrap:main"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = [
|
||||
"turnstone/**/*.py",
|
||||
"turnstone/prompts/**/*.md",
|
||||
"turnstone/tools/*.json",
|
||||
"turnstone/ui/static/*.html",
|
||||
"turnstone/ui/static/*.css",
|
||||
|
||||
@@ -41,6 +41,21 @@ class TestHashRingBuckets:
|
||||
# Empty list returns 0
|
||||
assert storage.assign_buckets([], "node-x") == 0
|
||||
|
||||
def test_assign_large_list_exceeds_chunk_size(self, storage):
|
||||
"""Regression: lists larger than chunk_size must not hit param limits."""
|
||||
n = 1200 # exceeds SQLite chunk_size (500) and exercises multi-chunk path
|
||||
storage.seed_ring_buckets([(i, "node-a") for i in range(n)])
|
||||
count = storage.assign_buckets(list(range(n)), "node-b")
|
||||
assert count == n
|
||||
rows = storage.list_ring_buckets()
|
||||
assert all(r["node_id"] == "node-b" for r in rows)
|
||||
|
||||
def test_assign_deduplicates_input(self, storage):
|
||||
"""Duplicates in the input list should not inflate rowcount."""
|
||||
storage.seed_ring_buckets([(0, "node-a"), (1, "node-a")])
|
||||
count = storage.assign_buckets([0, 1, 0, 1, 0], "node-b")
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestBucketStats:
|
||||
def test_increment_creates_row(self, storage):
|
||||
|
||||
@@ -37,3 +37,55 @@ class TestStripHtml:
|
||||
def test_self_closing_tags(self):
|
||||
result = strip_html("hello<br/>world")
|
||||
assert result == "helloworld"
|
||||
|
||||
# -- invisible element stripping -----------------------------------------
|
||||
|
||||
def test_strips_script_content(self):
|
||||
html = "<p>before</p><script>var x = 1;</script><p>after</p>"
|
||||
result = strip_html(html)
|
||||
assert "var x" not in result
|
||||
assert "before" in result
|
||||
assert "after" in result
|
||||
|
||||
def test_strips_style_content(self):
|
||||
html = "<style>.foo { color: red; }</style><p>visible</p>"
|
||||
result = strip_html(html)
|
||||
assert "color" not in result
|
||||
assert "visible" in result
|
||||
|
||||
def test_strips_template_content(self):
|
||||
html = "<template><div>hidden</div></template><p>shown</p>"
|
||||
result = strip_html(html)
|
||||
assert "hidden" not in result
|
||||
assert "shown" in result
|
||||
|
||||
def test_strips_noscript_content(self):
|
||||
html = "<noscript>Enable JS</noscript><p>content</p>"
|
||||
result = strip_html(html)
|
||||
assert "Enable JS" not in result
|
||||
assert "content" in result
|
||||
|
||||
def test_strips_multiple_script_blocks(self):
|
||||
html = "<script>a()</script><p>middle</p><script>b()</script>"
|
||||
result = strip_html(html)
|
||||
assert "a()" not in result
|
||||
assert "b()" not in result
|
||||
assert "middle" in result
|
||||
|
||||
def test_strips_multiline_script(self):
|
||||
html = "<script>\nfunction foo() {\n return 1;\n}\n</script><p>ok</p>"
|
||||
result = strip_html(html)
|
||||
assert "function" not in result
|
||||
assert "ok" in result
|
||||
|
||||
def test_strips_script_case_insensitive(self):
|
||||
html = "<SCRIPT>code()</SCRIPT><p>text</p>"
|
||||
result = strip_html(html)
|
||||
assert "code()" not in result
|
||||
assert "text" in result
|
||||
|
||||
def test_strips_script_with_attributes(self):
|
||||
html = '<script type="text/javascript" src="app.js">init();</script><p>done</p>'
|
||||
result = strip_html(html)
|
||||
assert "init()" not in result
|
||||
assert "done" in result
|
||||
|
||||
@@ -759,6 +759,8 @@ class TestTitleRetry:
|
||||
"""_generate_title resets _title_generated on failure."""
|
||||
|
||||
def test_title_generated_reset_on_failure(self, tmp_db):
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -767,6 +769,7 @@ class TestTitleRetry:
|
||||
]
|
||||
# Mock provider to raise
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.side_effect = RuntimeError("API error")
|
||||
|
||||
session._generate_title()
|
||||
@@ -774,6 +777,8 @@ class TestTitleRetry:
|
||||
assert session._title_generated is False
|
||||
|
||||
def test_title_generated_stays_true_on_success(self, tmp_db):
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -783,6 +788,7 @@ class TestTitleRetry:
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
with patch("turnstone.core.session.update_workstream_title"):
|
||||
@@ -793,6 +799,8 @@ class TestTitleRetry:
|
||||
|
||||
def test_title_skipped_after_resume_changes_ws_id(self, tmp_db):
|
||||
"""If ws_id changes (via resume) during title generation, discard the result."""
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = _make_session()
|
||||
session._title_generated = True
|
||||
session.messages = [
|
||||
@@ -803,6 +811,7 @@ class TestTitleRetry:
|
||||
result = MagicMock()
|
||||
result.content = "Test Title"
|
||||
session._provider = MagicMock()
|
||||
session._provider.get_capabilities.return_value = ModelCapabilities()
|
||||
session._provider.create_completion.return_value = result
|
||||
|
||||
# Simulate resume() changing ws_id while title generation is in flight
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""Tests for the storage backend registry."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.storage import get_storage, init_storage, reset_storage
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
from turnstone.core.storage import (
|
||||
StorageUnavailableError,
|
||||
get_storage,
|
||||
init_storage,
|
||||
reset_storage,
|
||||
)
|
||||
from turnstone.core.storage._postgresql import PostgreSQLBackend
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@@ -52,3 +61,69 @@ class TestResetStorage:
|
||||
init_storage("sqlite", path=str(tmp_path / "test2.db"), run_migrations=False)
|
||||
s2 = get_storage()
|
||||
assert s1 is not s2
|
||||
|
||||
|
||||
class TestConnUnavailableLogging:
|
||||
"""Test that _conn() deduplicates DB unavailable/restored logging."""
|
||||
|
||||
def _make_backend(self, tmp_path):
|
||||
"""Create a minimal SQLite backend for testing _conn()."""
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
return SQLiteBackend(str(tmp_path / "test.db"), create_tables=True)
|
||||
|
||||
def test_logs_unavailable_once(self, tmp_path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
backend = self._make_backend(tmp_path)
|
||||
with patch.object(backend, "_engine") as mock_engine:
|
||||
mock_engine.connect.side_effect = sa.exc.OperationalError(
|
||||
"conn", {}, Exception("refused")
|
||||
)
|
||||
for _ in range(3):
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
unavailable_msgs = [r for r in caplog.records if "database.unavailable" in r.message]
|
||||
assert len(unavailable_msgs) == 1
|
||||
|
||||
def test_logs_restored_on_recovery(self, tmp_path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.INFO)
|
||||
backend = self._make_backend(tmp_path)
|
||||
# Simulate outage
|
||||
with patch.object(backend, "_engine") as mock_engine:
|
||||
mock_engine.connect.side_effect = sa.exc.OperationalError(
|
||||
"conn", {}, Exception("refused")
|
||||
)
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
assert backend._db_unavailable is True
|
||||
# Real connection — should log restored
|
||||
caplog.clear()
|
||||
with backend._conn():
|
||||
pass
|
||||
restored_msgs = [r for r in caplog.records if "database.connection_restored" in r.message]
|
||||
assert len(restored_msgs) == 1
|
||||
assert backend._db_unavailable is False
|
||||
|
||||
def test_postgresql_conn_raises_storage_unavailable(self) -> None:
|
||||
import threading
|
||||
|
||||
backend = PostgreSQLBackend.__new__(PostgreSQLBackend)
|
||||
backend._db_unavailable = False
|
||||
backend._db_unavailable_lock = threading.Lock()
|
||||
|
||||
def _raise_op_error():
|
||||
raise sa.exc.OperationalError("conn", {}, Exception("refused"))
|
||||
|
||||
mock_engine = type(
|
||||
"E",
|
||||
(),
|
||||
{
|
||||
"connect": staticmethod(_raise_op_error),
|
||||
"url": sa.engine.make_url("postgresql://user:pass@localhost/db"),
|
||||
},
|
||||
)()
|
||||
backend._engine = mock_engine
|
||||
with pytest.raises(StorageUnavailableError), backend._conn():
|
||||
pass # pragma: no cover
|
||||
assert backend._db_unavailable is True
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.0.2"
|
||||
|
||||
@@ -282,10 +282,14 @@ def main() -> None:
|
||||
|
||||
async def _heartbeat_loop() -> None:
|
||||
"""Periodically update service heartbeat."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await asyncio.to_thread(storage.heartbeat_service, "channel", service_id)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("channel.heartbeat_failed")
|
||||
|
||||
|
||||
@@ -289,9 +289,13 @@ class ClusterCollector:
|
||||
|
||||
def _discovery_loop(self) -> None:
|
||||
"""Periodically scan the service registry for active nodes."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
self._discover_nodes()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("Node discovery error")
|
||||
time.sleep(self._discovery_interval)
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import structlog
|
||||
|
||||
from turnstone.core.hash_ring import RING_SIZE, RingNode, bucket_of
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.console.collector import ClusterCollector
|
||||
@@ -149,6 +150,8 @@ class Rebalancer:
|
||||
result = self.rebalance_once(trigger=trigger)
|
||||
self._last_result = result
|
||||
self._record_result_metrics(result)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("rebalancer.error")
|
||||
finally:
|
||||
|
||||
@@ -90,9 +90,13 @@ class TaskScheduler:
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""Main scheduler loop — tick then sleep."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("scheduler.tick_error")
|
||||
self._stop_event.wait(self._check_interval)
|
||||
|
||||
@@ -1167,10 +1167,14 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
import asyncio
|
||||
|
||||
async def _console_heartbeat() -> None:
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
storage.heartbeat_service("console", "console")
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.warning("console.heartbeat_failed", exc_info=True)
|
||||
|
||||
|
||||
@@ -543,9 +543,13 @@ class AnthropicProvider:
|
||||
if extra_params and "thinking_budget_tokens" in extra_params:
|
||||
budget = extra_params["thinking_budget_tokens"]
|
||||
if budget > 0:
|
||||
# Budget must leave room for the response
|
||||
# Budget must be strictly less than max_tokens (API requirement).
|
||||
# If max_tokens is too small to fit even a minimal thinking
|
||||
# budget alongside the response, disable thinking entirely.
|
||||
if budget >= max_tokens:
|
||||
budget = max(1024, max_tokens - 1024)
|
||||
budget = max_tokens - 1024
|
||||
if budget < 1:
|
||||
return {}
|
||||
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
|
||||
return {}
|
||||
|
||||
|
||||
+53
-31
@@ -924,10 +924,8 @@ class ChatSession:
|
||||
if asst_msg:
|
||||
snippet += f"\nAssistant: {asst_msg}"
|
||||
snippet += "\n\nTitle:"
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=[
|
||||
result = self._utility_completion(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
@@ -942,9 +940,6 @@ class ChatSession:
|
||||
{"role": "user", "content": snippet},
|
||||
],
|
||||
max_tokens=200,
|
||||
temperature=0.3,
|
||||
reasoning_effort="low",
|
||||
extra_params=self._provider_extra_params(reasoning_effort="low"),
|
||||
)
|
||||
raw = (result.content or "").strip()
|
||||
# Take first line, strip quotes
|
||||
@@ -1281,6 +1276,33 @@ class ChatSession:
|
||||
return {"chat_template_kwargs": kwargs}
|
||||
return None
|
||||
|
||||
def _utility_completion(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
max_tokens: int = 4096,
|
||||
temperature: float = 0.3,
|
||||
reasoning_effort: str = "low",
|
||||
) -> CompletionResult:
|
||||
"""Run a lightweight internal completion (title gen, compaction, extraction).
|
||||
|
||||
Threads ``reasoning_effort`` through both the direct keyword (for
|
||||
commercial providers) and ``extra_params`` (for local model servers)
|
||||
so callers don't need to duplicate it. ``max_tokens`` is clamped to
|
||||
the model's advertised output limit so small models don't error.
|
||||
"""
|
||||
caps = self._get_capabilities()
|
||||
clamped = min(max_tokens, caps.max_output_tokens) if caps.max_output_tokens else max_tokens
|
||||
return self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
max_tokens=clamped,
|
||||
temperature=temperature,
|
||||
reasoning_effort=reasoning_effort,
|
||||
extra_params=self._provider_extra_params(reasoning_effort=reasoning_effort),
|
||||
)
|
||||
|
||||
# -- tool search helpers --------------------------------------------------
|
||||
|
||||
def _get_active_tools(self) -> list[dict[str, Any]] | None:
|
||||
@@ -2482,14 +2504,9 @@ class ChatSession:
|
||||
result: CompletionResult | None = None
|
||||
for attempt in range(self._MAX_RETRIES + 1):
|
||||
try:
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=summary_msgs,
|
||||
result = self._utility_completion(
|
||||
summary_msgs,
|
||||
max_tokens=summary_max_tokens,
|
||||
temperature=0.3,
|
||||
reasoning_effort="low",
|
||||
extra_params=self._provider_extra_params(reasoning_effort="low"),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -6167,26 +6184,30 @@ class ChatSession:
|
||||
return call_id, msg
|
||||
|
||||
if not text.strip():
|
||||
return call_id, "(empty response from URL)"
|
||||
msg = "Error: fetch returned empty response"
|
||||
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
|
||||
return call_id, msg
|
||||
|
||||
original_len = len(text)
|
||||
self.ui.on_info(f"fetched {original_len} chars, extracting...")
|
||||
|
||||
# Phase 2: truncate for summarization context
|
||||
max_content = 50_000
|
||||
# Phase 2: truncate for summarization context.
|
||||
# Reserve ~25% of the context window for the extraction prompt
|
||||
# overhead (system message, URL, question) and response tokens.
|
||||
# Convert token budget to chars using the calibrated ratio.
|
||||
max_content = int(self.context_window * self._chars_per_token * 0.75)
|
||||
max_content = min(max(max_content, 50_000), 500_000) # 50k–500k
|
||||
if len(text) > max_content:
|
||||
text = (
|
||||
text[: max_content // 2]
|
||||
+ f"\n\n... [{len(text) - max_content} chars omitted] ...\n\n"
|
||||
+ text[-(max_content // 2) :]
|
||||
)
|
||||
# Prefer the beginning — page content is usually top-heavy.
|
||||
text = text[:max_content] + f"\n\n... [{len(text) - max_content} chars truncated] ...\n"
|
||||
|
||||
# Phase 3: summarization API call
|
||||
# Phase 3: summarization API call.
|
||||
# Use a generous max_tokens so thinking models don't starve the
|
||||
# visible answer, and pass reasoning_effort="low" to avoid wasting
|
||||
# budget on deep reasoning for a simple extraction task.
|
||||
try:
|
||||
result = self._provider.create_completion(
|
||||
client=self.client,
|
||||
model=self.model,
|
||||
messages=[
|
||||
result = self._utility_completion(
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
@@ -6206,11 +6227,12 @@ class ChatSession:
|
||||
),
|
||||
},
|
||||
],
|
||||
max_tokens=2000,
|
||||
max_tokens=8192,
|
||||
temperature=0.2,
|
||||
extra_params=self._provider_extra_params(),
|
||||
)
|
||||
answer = result.content or "(no answer)"
|
||||
answer = result.content or ""
|
||||
if not answer:
|
||||
answer = "Error: extraction returned no answer"
|
||||
except Exception as e:
|
||||
answer = f"Extraction failed (page was fetched but summarization errored): {e}"
|
||||
|
||||
@@ -6218,7 +6240,7 @@ class ChatSession:
|
||||
call_id,
|
||||
"web_fetch",
|
||||
answer,
|
||||
is_error=answer.startswith("Extraction failed"),
|
||||
is_error=answer.startswith(("Error:", "Extraction failed")),
|
||||
)
|
||||
|
||||
return call_id, answer
|
||||
|
||||
@@ -4,10 +4,16 @@ Supports SQLite (default, zero-config) and PostgreSQL (multi-node, production).
|
||||
"""
|
||||
|
||||
from turnstone.core.storage._protocol import StorageBackend
|
||||
from turnstone.core.storage._registry import get_storage, init_storage, reset_storage
|
||||
from turnstone.core.storage._registry import (
|
||||
StorageUnavailableError,
|
||||
get_storage,
|
||||
init_storage,
|
||||
reset_storage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"StorageBackend",
|
||||
"StorageUnavailableError",
|
||||
"get_storage",
|
||||
"init_storage",
|
||||
"reset_storage",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,14 @@ log = get_logger(__name__)
|
||||
_storage: StorageBackend | None = None
|
||||
|
||||
|
||||
class StorageUnavailableError(Exception):
|
||||
"""Raised when the database is unreachable.
|
||||
|
||||
The storage layer has already logged a clean one-liner — callers
|
||||
should catch this to avoid duplicate tracebacks.
|
||||
"""
|
||||
|
||||
|
||||
def init_storage(
|
||||
backend: str = "sqlite",
|
||||
*,
|
||||
|
||||
+251
-207
File diff suppressed because it is too large
Load Diff
@@ -271,9 +271,13 @@ class WatchRunner:
|
||||
# -- Main loop -----------------------------------------------------------
|
||||
|
||||
def _run(self) -> None:
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("watch_runner.tick_error")
|
||||
self._stop_event.wait(self._check_interval)
|
||||
|
||||
@@ -6,14 +6,20 @@ import socket
|
||||
from html import unescape as _html_unescape
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_RE_INVISIBLE = re.compile(
|
||||
r"<(script|style|template|noscript)\b[^>]*>.*?</\1\s*>",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
_RE_TAGS = re.compile(r"<[^>]+>")
|
||||
_RE_WS = re.compile(r"[ \t]+")
|
||||
_RE_BLANKLINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def strip_html(html: str) -> str:
|
||||
"""Convert HTML to plain text: strip tags, decode entities, collapse whitespace."""
|
||||
text = _RE_TAGS.sub("", html)
|
||||
"""Convert HTML to plain text: strip invisible elements, tags, decode entities."""
|
||||
# Remove elements whose content should never appear as text
|
||||
text = _RE_INVISIBLE.sub("", html)
|
||||
text = _RE_TAGS.sub("", text)
|
||||
text = _html_unescape(text)
|
||||
text = _RE_WS.sub(" ", text)
|
||||
text = _RE_BLANKLINES.sub("\n\n", text)
|
||||
|
||||
@@ -2389,10 +2389,14 @@ async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
|
||||
async def _heartbeat_loop() -> None:
|
||||
"""Periodically update service heartbeat."""
|
||||
from turnstone.core.storage._registry import StorageUnavailableError
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await asyncio.to_thread(_svc_storage.heartbeat_service, "server", _svc_node_id)
|
||||
except StorageUnavailableError:
|
||||
pass # already logged by storage layer
|
||||
except Exception:
|
||||
log.exception("server.heartbeat_failed")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user