From 281c13fff08e5bc2712a2e7c185cb605fc573cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:32:45 +0000 Subject: [PATCH 01/14] SQLite: synchronous=NORMAL under WAL; period_cache WITHOUT ROWID New databases get the compact table; tools/period_cache_without_rowid.py converts an existing one. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- CLAUDE.md | 2 +- lib/store.py | 3 +- tests/test_period_cache_tool.py | 25 ++++++++++++++++ tests/test_store.py | 6 ++++ tools/period_cache_without_rowid.py | 45 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 tests/test_period_cache_tool.py create mode 100644 tools/period_cache_without_rowid.py diff --git a/CLAUDE.md b/CLAUDE.md index 2079044..5993fe7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ MCP data tools and web search. User-facing docs are in `README.md`; this file is drafts), `commands.py` (/credits), `access.py` (who is an admin). `features/`: `holding_news.py`, `movers.py`, `dm_buttons.py`, `post.py`, `periods/` (local `period_returns` tool, cache-first; `windows.py`, `rows.py`, `backfill.py` = PERIOD_BACKFILL loop). - `app.py` wiring/startup; `context.py` `Ctx` (settings, store, backend, registry, bot). - `lib/` holds only what the bot needs to run. Developer helpers live in `tools/`: `report.py` (`make prompt-report`), - `capture.py` (real MCP output for fixtures); the bot never imports them. + `capture.py` (real MCP output for fixtures), `period_cache_without_rowid.py` (one-off DB conversion); the bot never imports them. ## Request flow `handlers.on_message` logs the message -> `_trigger` (mention / reply-to-bot / DM / movers list) -> diff --git a/lib/store.py b/lib/store.py index b58850d..08e01d8 100644 --- a/lib/store.py +++ b/lib/store.py @@ -38,7 +38,7 @@ -- for that window. movers = the holdings that moved most, as JSON [[code, gain, gain_pct], ...]. CREATE TABLE IF NOT EXISTS period_cache ( portfolio_id INTEGER, start TEXT, end TEXT, pct REAL, gain REAL, currency TEXT, movers TEXT, - v INTEGER, PRIMARY KEY (portfolio_id, start, end)); + v INTEGER, PRIMARY KEY (portfolio_id, start, end)) WITHOUT ROWID; """ PERIOD_CACHE_VERSION = 1 # bump when the stored shape changes: rows of another version are dropped @@ -77,6 +77,7 @@ def __init__(self, path: str): self._lock = threading.RLock() self.bot_id: int | None = None # set once the bot has started (DMs are stored per bot) self._run("PRAGMA journal_mode=WAL") + self._run("PRAGMA synchronous=NORMAL") # with WAL: no fsync per commit; a power cut may lose the last few with self._lock: self._conn.executescript(SCHEMA) # Drop "NOTHING" digests sent by an earlier version, so they aren't fed to the model. diff --git a/tests/test_period_cache_tool.py b/tests/test_period_cache_tool.py new file mode 100644 index 0000000..4102099 --- /dev/null +++ b/tests/test_period_cache_tool.py @@ -0,0 +1,25 @@ +import sqlite3 + +from lib.store import Store +from tools.period_cache_without_rowid import convert + +OLD = """CREATE TABLE period_cache ( + portfolio_id INTEGER, start TEXT, end TEXT, pct REAL, gain REAL, currency TEXT, movers TEXT, + v INTEGER, PRIMARY KEY (portfolio_id, start, end))""" + + +def test_an_old_period_cache_keeps_its_rows_and_becomes_without_rowid(tmp_path): + path = str(tmp_path / "x.db") + db = sqlite3.connect(path) + db.execute(OLD) + db.executemany("INSERT INTO period_cache VALUES (?,?,?,?,?,?,?,?)", + [(1, f"2026-01-0{d}", f"2026-01-0{d}", 1.5, 10.0, "AUD", "[]", 1) for d in range(1, 4)]) + db.commit() + db.close() + assert convert(path) == 3 + assert convert(path) is None # already converted + db = sqlite3.connect(path) + assert "WITHOUT ROWID" in db.execute("SELECT sql FROM sqlite_master WHERE name = 'period_cache'").fetchone()[0] + assert db.execute("SELECT COUNT(*) FROM period_cache").fetchone()[0] == 3 + db.close() + assert Store(path).period_count(1, "2026-01-01", "2026-01-31") == 3 # the bot opens it fine diff --git a/tests/test_store.py b/tests/test_store.py index 397dde5..658b5f2 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -109,3 +109,9 @@ def test_period_flush_by_date_range_removes_every_window_that_overlaps_it(tmp_pa row("2023-01-01", "2023-12-31"), row("2025-01-07", "2025-01-13")]) assert s.period_flush(7, "2024-01-01", "2024-12-31") == 3 # the week spanning New Year both ways, and June assert sorted(s.period_get(7, "2000-01-01", "2100-01-01")) == [("2023-01-01", "2023-12-31"), ("2025-01-07", "2025-01-13")] + + +def test_store_uses_wal_with_normal_sync_and_a_without_rowid_period_cache(tmp_path): + st = Store(str(tmp_path / "x.db")) + assert st._run("PRAGMA synchronous") == [(1,)] + assert "WITHOUT ROWID" in st._run("SELECT sql FROM sqlite_master WHERE name = 'period_cache'")[0][0] diff --git a/tools/period_cache_without_rowid.py b/tools/period_cache_without_rowid.py new file mode 100644 index 0000000..201b928 --- /dev/null +++ b/tools/period_cache_without_rowid.py @@ -0,0 +1,45 @@ +"""One-off: convert an existing database's period_cache to WITHOUT ROWID (stop the bot first).""" + +from __future__ import annotations + +import sqlite3 +import sys + +# Keep in step with the period_cache table in lib/store.py. +CREATE = """CREATE TABLE period_cache ( + portfolio_id INTEGER, start TEXT, end TEXT, pct REAL, gain REAL, currency TEXT, movers TEXT, + v INTEGER, PRIMARY KEY (portfolio_id, start, end)) WITHOUT ROWID""" + + +def convert(path: str) -> int | None: + """Rebuild period_cache keeping every row. Returns the row count, or None if there was nothing to do.""" + db = sqlite3.connect(path, isolation_level=None) + try: + row = db.execute("SELECT sql FROM sqlite_master WHERE name = 'period_cache'").fetchone() + if row is None or "WITHOUT ROWID" in row[0]: + return None + db.execute("BEGIN") + db.execute("ALTER TABLE period_cache RENAME TO period_cache_old") + db.execute(CREATE) + db.execute("INSERT INTO period_cache SELECT * FROM period_cache_old") + count = db.execute("SELECT COUNT(*) FROM period_cache").fetchone()[0] + db.execute("DROP TABLE period_cache_old") + db.execute("COMMIT") + db.execute("VACUUM") + return count + finally: + db.close() + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("usage: python -m tools.period_cache_without_rowid (stop the bot first)") + return 2 + count = convert(argv[1]) + print("period_cache is already WITHOUT ROWID (or absent); nothing to do" if count is None + else f"converted period_cache: {count} rows kept") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From d2a8d716ba087bcf15c692ed5c2f0e8925a885bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:42:07 +0000 Subject: [PATCH 02/14] /holdingnews says when the provider is busy; Sharesight MCP from robdevops fork Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/features/holding_news.py | 9 ++++++--- lib/llm/base.py | 1 + lib/telegram/handlers.py | 3 +-- mcp_servers.json | 2 +- tests/test_features.py | 8 ++++++++ 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/lib/features/holding_news.py b/lib/features/holding_news.py index fe561e0..3d9831f 100644 --- a/lib/features/holding_news.py +++ b/lib/features/holding_news.py @@ -18,7 +18,7 @@ from .. import config from ..ask import ask from ..context import Ctx -from ..llm.base import is_busy +from ..llm.base import BUSY_REPLY, is_busy from ..llm.gate import Route from ..mcp.results import table_records from ..prompts import HOLDING_NEWS_PROMPT @@ -233,11 +233,14 @@ async def on_command(ctx: Ctx, msg: Message) -> None: return draft = Draft(ctx.bot, msg.chat_id) await draft.start() + busy: list[str] = [] try: - result = (await run_holding_news(ctx, only_username=username)).get(username) + result = (await run_holding_news(ctx, only_username=username, busy=busy)).get(username) finally: draft.stop() - if result: + if busy: + await asyncio.to_thread(save, await msg.reply_text(BUSY_REPLY)) + elif result: await send_holding_news(ctx, msg.chat_id, *result) else: await asyncio.to_thread( diff --git a/lib/llm/base.py b/lib/llm/base.py index 15f72da..6272d0a 100644 --- a/lib/llm/base.py +++ b/lib/llm/base.py @@ -9,6 +9,7 @@ from ..mcp.schema import ToolDef BUSY_STATUSES = (429, 503, 529) +BUSY_REPLY = "The AI provider is at capacity right now. Please try again in a few minutes." class Overloaded(RuntimeError): diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index ad131b6..b4f669e 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -17,7 +17,7 @@ from ..context import Ctx from ..features import dm_buttons, holding_news, movers, periods, post from ..history import compact, format_rows -from ..llm.base import is_busy +from ..llm.base import BUSY_REPLY, is_busy from ..llm.gate import Route, is_portfolio_question, route from ..msgtext import describe, sender_name from ..prompts import MOVERS_EXPLAIN, PRESET_PROMPT, chat_prompt, down_note @@ -30,7 +30,6 @@ log = logging.getLogger("bot") PORTFOLIO_ADMINS_ONLY = "Portfolio data is for admins only." -BUSY_REPLY = "The AI provider is at capacity right now. Please try again in a few minutes." FOLLOW_UP_WORDS = 12 # a message this short right after the bot's reply may be a follow-up to it FOLLOW_UP_SECONDS = 30 * 60 FOLLOW_UP_CHARS = 400 # of the bot's reply that the tool gate also reads diff --git a/mcp_servers.json b/mcp_servers.json index da29fe0..c4124a7 100644 --- a/mcp_servers.json +++ b/mcp_servers.json @@ -17,7 +17,7 @@ "sharesight": { "description": "Sharesight portfolios. Never ask the user for portfolio IDs or holdings: call list_portfolios first (it names every portfolio and its owner), then get_performance_report for the ones involved. A report's holdings list is exactly their current positions, with quantity, value and gains. When comparing portfolios or explaining a result, write 2-3 short paragraphs: each portfolio's return, then its tailwinds (biggest contributors, with numbers) and headwinds (biggest detractors), and what explains the gap.", "command": "npx", - "args": ["-y", "github:sryburn/sharesight-mcp"], + "args": ["-y", "github:robdevops/sharesight-mcp"], "env": { "SHARESIGHT_CLIENT_ID": "${SHARESIGHT_CLIENT_ID}", "SHARESIGHT_CLIENT_SECRET": "${SHARESIGHT_CLIENT_SECRET}" diff --git a/tests/test_features.py b/tests/test_features.py index d7b5120..dbf78dd 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -261,3 +261,11 @@ async def test_flushcache_command_is_admin_only_and_flushes_by_portfolio_and_per every = user_msg(ctx.bot, "/flushcache", user_id=5, mid=5) await commands.flush_cache(ctx, update_for(every), N(args=[])) assert "Flushed 4 cached periods (all portfolios)" in every.replies[0]["text"] + + +async def test_holdingnews_command_says_when_the_provider_is_busy(env, store): + ctx = holding_ctx(env, store, [Overloaded("at capacity")]) + m = user_msg(ctx.bot, "/holdingnews", chat_id=5, user_id=5) + m.from_user.username = "bob_llama" + await holding_news.on_command(ctx, m) + assert "at capacity" in m.replies[-1]["text"] and "No major news" not in m.replies[-1]["text"] From d886028dc8767cc1086f56288d5c42682c4c4210 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:18:42 +0000 Subject: [PATCH 03/14] Route market-session questions to tools and strip tool remarks from replies "at close" matched no gate keyword, so the model got the no-tools prompt and answered from the transcript, opening with "(no tool calls needed)". Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/gate.py | 3 ++- lib/llm/runner.py | 11 ++++++++--- lib/prompts.py | 4 ++-- lib/textfmt.py | 3 +++ tests/test_runner.py | 7 +++++++ tests/test_textfmt.py | 7 +++++++ 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/llm/gate.py b/lib/llm/gate.py index b80dbc0..bd0a12f 100644 --- a/lib/llm/gate.py +++ b/lib/llm/gate.py @@ -33,7 +33,8 @@ r"\b(performance|gains?|positions?|winners?|losers?|p&l|pnl|(?:out|under)perform\w*|ahead of|behind)\b", re.I) LIVE_WORDS = re.compile( r"\b(news|today|latest|yesterday|tonight|this week|right now|breaking|announce\w*|who won|" - r"score|weather|happening|headlines?|current\w*|updates?|recent\w*|just (now|in))\b", re.I) + r"score|weather|happening|headlines?|current\w*|updates?|recent\w*|just (now|in)|" + r"(?:at|the|into|after) (?:the )?(?:close|open)|pre-?market|after[- ]hours|closing|opening)\b", re.I) @dataclass(frozen=True) diff --git a/lib/llm/runner.py b/lib/llm/runner.py index cfefe87..5fbf958 100644 --- a/lib/llm/runner.py +++ b/lib/llm/runner.py @@ -13,7 +13,7 @@ from .. import config from ..mcp.server import Registry -from ..textfmt import TOOL_SYNTAX_RE +from ..textfmt import LEADING_TOOL_REMARK_RE, TOOL_SYNTAX_RE from .base import Answer, Backend, Call, Request, Step, Usage log = logging.getLogger("bot") @@ -28,6 +28,11 @@ TOO_MANY_SEARCHES = "Skipped: too many searches at once. Use what you already have." +def clean_text(text: str) -> str: + """The model's answer without printed tool-call syntax or a leading remark about tools.""" + return LEADING_TOOL_REMARK_RE.sub("", TOOL_SYNTAX_RE.sub("", text).strip()).strip() + + def call_args(arguments: str) -> dict: """A tool call's JSON arguments as a dict ({} if they aren't a JSON object).""" try: @@ -201,7 +206,7 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo log.info("%.1fs, %d rounds, %d tools: in %d (%.0f%% cached) out %d, $%.4f", time.monotonic() - t0, rounds, tool_calls, total.tokens_in, total.cached_pct, total.tokens_out, total.cost) - text = TOOL_SYNTAX_RE.sub("", step.text).strip() + text = clean_text(step.text) if not text and step.finish == "length" and tool_calls: # The model spent the whole cap thinking about the tool results. Keep them (answering # from memory without them invents things): ask again with more room and less thinking. @@ -211,7 +216,7 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo step = await _step(backend, conv, more, "none") total.add(step.usage) log.info("Recovery round: in %d out %d, %s", step.usage.tokens_in, step.usage.tokens_out, step.finish) - text = TOOL_SYNTAX_RE.sub("", step.text).strip() + text = clean_text(step.text) if not text: if step.finish == "length": raise RuntimeError("hit the token cap before writing an answer") diff --git a/lib/prompts.py b/lib/prompts.py index d9cb3da..9d66c6d 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -24,12 +24,12 @@ NO_SEARCH_NOTE = "You have no web search: say plainly what you can't check instead of guessing.\n" SIMPLE_NOTE = ("You have no live data tools for this message. If answering needs live prices, " f"news or portfolio data, reply with exactly {NEEDS_TOOLS} and nothing else. Never claim " - "to have looked something up.\n") + "to have looked something up, and never remark on tools or what you did or didn't use.\n") PARTIAL_NOTE = ("If you need a data source or search you don't have this time, reply with exactly " f"{NEEDS_TOOLS} and nothing else.\n") NO_TOOLS_NOTE = ("You have no web access and no tools this time. Answer from the chat history and " "what you know, say plainly what you can't check, and never output tool-call " - "syntax or JSON as text.\n") + "syntax or JSON as text, or any remark about tools.\n") def tools_note(servers: list[MCPServer]) -> str: diff --git a/lib/textfmt.py b/lib/textfmt.py index fa1cffa..2bd0fae 100644 --- a/lib/textfmt.py +++ b/lib/textfmt.py @@ -35,6 +35,9 @@ re.DOTALL | re.IGNORECASE, ) +# A model told it has no tools sometimes opens with a remark about it: "(no tool calls needed)". +LEADING_TOOL_REMARK_RE = re.compile(r"^\s*\((?:no|without)\s+(?:tool|search|lookup)[^)\n]*\)\s*", re.I) + def strip_disclaimer(text: str) -> str: """Models add these despite the prompt, and copy their own earlier ones from the transcript.""" diff --git a/tests/test_runner.py b/tests/test_runner.py index 17b3d38..0c70d02 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -242,6 +242,13 @@ def test_gate_live_question_gets_search_only(settings): assert r.search and not r.servers +@pytest.mark.parametrize("text", ["what happened at close?", "how did it go at the open", "any pre-market moves"]) +def test_gate_market_session_words_are_live(text, settings): + y, s = servers() + r = route(text, settings, registry(y, s)) + assert r.search and not r.simple + + def test_gate_full_when_forced_or_token_saver_off(settings, env): y, s = servers() assert route("hello", settings, registry(y, s), force_full=True).servers == [y, s] diff --git a/tests/test_textfmt.py b/tests/test_textfmt.py index e7d8ba4..3cf3b66 100644 --- a/tests/test_textfmt.py +++ b/tests/test_textfmt.py @@ -19,6 +19,13 @@ def test_tool_syntax_does_not_eat_the_answer(): assert t.TOOL_SYNTAX_RE.sub("", "").strip() == "" +def test_leading_tool_remark_is_stripped(): + from lib.llm.runner import clean_text + assert clean_text(" (no tool calls needed)Wasion (3393) -6%") == "Wasion (3393) -6%" + assert clean_text("(No search needed) Fine.") == "Fine." + assert clean_text("Wasion (no tool calls needed) fell") == "Wasion (no tool calls needed) fell" + + def test_tg_tag_regex_keeps_bare_lt(): s = lambda x: t.TG_TAG_RE.sub("", x) # noqa: E731 assert s("NVDA <5% ok x") == "NVDA <5% ok x" From 95875cffceb504f70cdc0f9e78b7e67ad19d4547 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:21:11 +0000 Subject: [PATCH 04/14] Make trading-session questions fetch current prices and news Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/gate.py | 12 +++++++++--- lib/prompts.py | 5 ++++- lib/telegram/handlers.py | 11 +++++++---- tests/test_runner.py | 9 +++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/llm/gate.py b/lib/llm/gate.py index bd0a12f..6f05d10 100644 --- a/lib/llm/gate.py +++ b/lib/llm/gate.py @@ -33,8 +33,10 @@ r"\b(performance|gains?|positions?|winners?|losers?|p&l|pnl|(?:out|under)perform\w*|ahead of|behind)\b", re.I) LIVE_WORDS = re.compile( r"\b(news|today|latest|yesterday|tonight|this week|right now|breaking|announce\w*|who won|" - r"score|weather|happening|headlines?|current\w*|updates?|recent\w*|just (now|in)|" - r"(?:at|the|into|after) (?:the )?(?:close|open)|pre-?market|after[- ]hours|closing|opening)\b", re.I) + r"score|weather|happening|headlines?|current\w*|updates?|recent\w*|just (now|in))\b", re.I) +# Asking about a trading session ("what happened at close"): answers must be fetched, not recalled. +SESSION_WORDS = re.compile( + r"\b(?:(?:at|the|into|after) (?:the )?(?:close|open)|pre-?market|after[- ]hours|closing|opening)\b", re.I) @dataclass(frozen=True) @@ -66,6 +68,10 @@ def is_portfolio_question(text: str, st: Settings) -> bool: return bool(PORTFOLIO_WEAK.search(text)) and not _has_ticker(text) +def is_session_question(text: str) -> bool: + return bool(SESSION_WORDS.search(text)) + + def route(text: str, st: Settings, registry: Registry, *, force_full: bool = False) -> Route: """Tools for this message. With TOKEN_SAVER off, or `force_full` (a search/data request such as a movers list, a preset button or a retry after NEEDS_TOOLS), everything is offered.""" @@ -74,7 +80,7 @@ def route(text: str, st: Settings, registry: Registry, *, force_full: bool = Fal return Route(up, st.search) portfolio = is_portfolio_question(text, st) market = portfolio or bool(MARKET_WORDS.search(text)) or _has_ticker(text) - live = bool(LIVE_WORDS.search(text)) + live = is_session_question(text) or bool(LIVE_WORDS.search(text)) servers = [s for s in up if (portfolio if s.cfg.get("gate") == "portfolio" else market)] search = st.search and (market or live) simple = not (servers or search) diff --git a/lib/prompts.py b/lib/prompts.py index 9d66c6d..9ac8369 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -69,7 +69,7 @@ def down_note(down: list[MCPServer]) -> str: def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str | None, msg_id: int, now: str, down: str = "", saver: bool = True, middle: str = "", - fresh: bool = False) -> str: + fresh: bool = False, session: bool = False) -> str: """The user turn: transcript, the replied-to message, and the volatile tail (kept last so the cached prefix before it is unchanged).""" head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}" @@ -89,6 +89,9 @@ def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str "for anything else asked, such as prices or a comparison with market data); don't reuse " "numbers or refusals from earlier replies. When comparing portfolios, or asked \"why\", " "write 2-3 short paragraphs on the tailwinds and headwinds of each portfolio, not a summary.") + if session: # a trading-session question: earlier replies and movers lists are stale by now + tail += (" Fetch current prices and current news with your tools before answering, and say why " + "each stock moved. Don't reuse numbers or reasons from earlier messages in the transcript.") return head + tail + down diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index b4f669e..69356b4 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -18,7 +18,7 @@ from ..features import dm_buttons, holding_news, movers, periods, post from ..history import compact, format_rows from ..llm.base import BUSY_REPLY, is_busy -from ..llm.gate import Route, is_portfolio_question, route +from ..llm.gate import Route, is_portfolio_question, is_session_question, route from ..msgtext import describe, sender_name from ..prompts import MOVERS_EXPLAIN, PRESET_PROMPT, chat_prompt, down_note from ..store import is_dm @@ -179,7 +179,8 @@ async def _gate_text(self, msg: Message, trig: Trigger) -> str: text += " " + compact(prev.text)[:FOLLOW_UP_CHARS] return text - async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False) -> str: + async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False, + session: bool = False) -> str: ctx, st = self.ctx, self.ctx.st rows = await asyncio.to_thread(ctx.store.history, msg.chat_id, st.history_limit) transcript = format_rows(rows, ctx.self_name, st.tz, line_max=st.history_line_max, @@ -187,7 +188,8 @@ async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: return chat_prompt( transcript=transcript, sender=sender_name(msg), private=trig.private, reply_quote=self._quote(trig), msg_id=msg.message_id, now=ctx.now(), - down=down_note(ctx.registry.down()), saver=st.token_saver, middle=middle, fresh=fresh) + down=down_note(ctx.registry.down()), saver=st.token_saver, middle=middle, fresh=fresh, + session=session) # -- answering --------------------------------------------------------------------- async def _answer(self, msg: Message, trig: Trigger) -> None: @@ -257,7 +259,8 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s if chosen.simple: # the model must not be told it has no tools chosen = Route([], False) fresh = is_portfolio_question(context, st) and any(s.cfg.get("gate") == "portfolio" for s in chosen.servers) - parts = [{"type": "text", "text": await self._chat_text(msg, trig, fresh=fresh)}] + session = is_session_question(context) and not chosen.simple + parts = [{"type": "text", "text": await self._chat_text(msg, trig, fresh=fresh, session=session)}] parts += await self._images(msg, trig) answer = await ask(ctx, parts, chosen, cache_key=f"chat-{chat_id}", on_text=on_text, chat_id=chat_id, extra_tools=extra, diff --git a/tests/test_runner.py b/tests/test_runner.py index 0c70d02..0db51a6 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -249,6 +249,15 @@ def test_gate_market_session_words_are_live(text, settings): assert r.search and not r.simple +def test_session_question_prompt_demands_fresh_prices_and_news(): + from lib.llm.gate import is_session_question + from lib.prompts import chat_prompt + assert is_session_question("what happened at close?") and not is_session_question("hello") + kw = dict(transcript="t", sender="A", private=False, reply_quote=None, msg_id=1, now="now") + assert "current news" in chat_prompt(**kw, session=True) + assert "current news" not in chat_prompt(**kw) + + def test_gate_full_when_forced_or_token_saver_off(settings, env): y, s = servers() assert route("hello", settings, registry(y, s), force_full=True).servers == [y, s] From 573ff6101dab2a019df659b98a9536e17003b765 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:22:26 +0000 Subject: [PATCH 05/14] Force a search for trading-session questions Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/telegram/handlers.py | 1 + tests/test_handlers.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index 69356b4..8ab1550 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -263,6 +263,7 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s parts = [{"type": "text", "text": await self._chat_text(msg, trig, fresh=fresh, session=session)}] parts += await self._images(msg, trig) answer = await ask(ctx, parts, chosen, + must_search=session and chosen.search, # a session recap must not be recalled cache_key=f"chat-{chat_id}", on_text=on_text, chat_id=chat_id, extra_tools=extra, admin=admin) return format_answer(answer.text, movers_bots=st.movers_bots, summary=trig.movers) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ba7f5a5..4c01302 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -90,6 +90,14 @@ async def test_partial_route_that_needs_more_is_asked_again_with_everything(env, assert "NEEDS_TOOLS" in backend.seen[0]["system"] # the partial route told the model how to ask +async def test_session_question_forces_a_search(env, store): + ctx, backend, h = make_ctx(env, store, [step("fresh recap", searches=1)], servers=[FakeMcp("yahoo")]) + m = user_msg(ctx.bot, "@stockbot what happened at close?") + await run(h, m) + assert backend.seen[0]["choice"] == "required" and backend.seen[0]["search"] is True + assert "current news" in backend.seen[0]["prompt"] + + async def test_market_question_offers_tools_up_front(env, store): mcp = FakeMcp("yahoo") ctx, backend, h = make_ctx(env, store, [step("ok")], servers=[mcp]) From 7b22d532d8f121a1319c29717ab7a1809ac8075a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:24:35 +0000 Subject: [PATCH 06/14] Keep market data tools beside the forced search for session questions Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/ask.py | 8 ++++---- lib/llm/gate.py | 5 +++-- lib/telegram/handlers.py | 1 + tests/test_handlers.py | 1 + 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/ask.py b/lib/ask.py index 9c46368..c5b1b5e 100644 --- a/lib/ask.py +++ b/lib/ask.py @@ -44,17 +44,17 @@ def _public(servers: list) -> list: async def ask(ctx: Ctx, parts: list[dict], route: Route, *, must_search: bool = False, cache_key: str | None = None, on_text=None, kind: str = "chat", - chat_id: int = 0, extra_tools=(), admin: bool = True) -> Answer: + chat_id: int = 0, extra_tools=(), admin: bool = True, keep_tools: bool = False) -> Answer: """Ask the configured provider and record the usage. `route` says which tools to offer; - must_search forces a search (and offers nothing else). A message the gate gave fewer tools + must_search forces a tool call first (and offers only search, unless keep_tools). A message the gate gave fewer tools than exist that gets back NEEDS_TOOLS is asked again with everything on. A non-admin never gets the portfolio tools, in either request.""" st = ctx.st if must_search: if not st.search: raise RuntimeError("search is disabled, but this request needs it") - route = Route([], True) - elif not admin: + route = dataclasses.replace(route, search=True) if keep_tools else Route([], True) + if not admin: route = dataclasses.replace(route, servers=_public(route.servers)) saver = st.token_saver req = Request( diff --git a/lib/llm/gate.py b/lib/llm/gate.py index 6f05d10..43cf316 100644 --- a/lib/llm/gate.py +++ b/lib/llm/gate.py @@ -79,8 +79,9 @@ def route(text: str, st: Settings, registry: Registry, *, force_full: bool = Fal if force_full or not st.token_saver: return Route(up, st.search) portfolio = is_portfolio_question(text, st) - market = portfolio or bool(MARKET_WORDS.search(text)) or _has_ticker(text) - live = is_session_question(text) or bool(LIVE_WORDS.search(text)) + session = is_session_question(text) + market = portfolio or session or bool(MARKET_WORDS.search(text)) or _has_ticker(text) + live = session or bool(LIVE_WORDS.search(text)) servers = [s for s in up if (portfolio if s.cfg.get("gate") == "portfolio" else market)] search = st.search and (market or live) simple = not (servers or search) diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index 8ab1550..3b97a0a 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -264,6 +264,7 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s parts += await self._images(msg, trig) answer = await ask(ctx, parts, chosen, must_search=session and chosen.search, # a session recap must not be recalled + keep_tools=True, cache_key=f"chat-{chat_id}", on_text=on_text, chat_id=chat_id, extra_tools=extra, admin=admin) return format_answer(answer.text, movers_bots=st.movers_bots, summary=trig.movers) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 4c01302..21a9ba6 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -95,6 +95,7 @@ async def test_session_question_forces_a_search(env, store): m = user_msg(ctx.bot, "@stockbot what happened at close?") await run(h, m) assert backend.seen[0]["choice"] == "required" and backend.seen[0]["search"] is True + assert backend.seen[0]["tools"] == ["yahoo__get_quote"] # quotes stay available beside the search assert "current news" in backend.seen[0]["prompt"] From 54d6f4c3f6f6e70ae38021f51c22bb6205ebf042 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:26:02 +0000 Subject: [PATCH 07/14] Revert "Keep market data tools beside the forced search for session questions" This reverts commit 7b22d532d8f121a1319c29717ab7a1809ac8075a. --- lib/ask.py | 8 ++++---- lib/llm/gate.py | 5 ++--- lib/telegram/handlers.py | 1 - tests/test_handlers.py | 1 - 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/ask.py b/lib/ask.py index c5b1b5e..9c46368 100644 --- a/lib/ask.py +++ b/lib/ask.py @@ -44,17 +44,17 @@ def _public(servers: list) -> list: async def ask(ctx: Ctx, parts: list[dict], route: Route, *, must_search: bool = False, cache_key: str | None = None, on_text=None, kind: str = "chat", - chat_id: int = 0, extra_tools=(), admin: bool = True, keep_tools: bool = False) -> Answer: + chat_id: int = 0, extra_tools=(), admin: bool = True) -> Answer: """Ask the configured provider and record the usage. `route` says which tools to offer; - must_search forces a tool call first (and offers only search, unless keep_tools). A message the gate gave fewer tools + must_search forces a search (and offers nothing else). A message the gate gave fewer tools than exist that gets back NEEDS_TOOLS is asked again with everything on. A non-admin never gets the portfolio tools, in either request.""" st = ctx.st if must_search: if not st.search: raise RuntimeError("search is disabled, but this request needs it") - route = dataclasses.replace(route, search=True) if keep_tools else Route([], True) - if not admin: + route = Route([], True) + elif not admin: route = dataclasses.replace(route, servers=_public(route.servers)) saver = st.token_saver req = Request( diff --git a/lib/llm/gate.py b/lib/llm/gate.py index 43cf316..6f05d10 100644 --- a/lib/llm/gate.py +++ b/lib/llm/gate.py @@ -79,9 +79,8 @@ def route(text: str, st: Settings, registry: Registry, *, force_full: bool = Fal if force_full or not st.token_saver: return Route(up, st.search) portfolio = is_portfolio_question(text, st) - session = is_session_question(text) - market = portfolio or session or bool(MARKET_WORDS.search(text)) or _has_ticker(text) - live = session or bool(LIVE_WORDS.search(text)) + market = portfolio or bool(MARKET_WORDS.search(text)) or _has_ticker(text) + live = is_session_question(text) or bool(LIVE_WORDS.search(text)) servers = [s for s in up if (portfolio if s.cfg.get("gate") == "portfolio" else market)] search = st.search and (market or live) simple = not (servers or search) diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index 3b97a0a..8ab1550 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -264,7 +264,6 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s parts += await self._images(msg, trig) answer = await ask(ctx, parts, chosen, must_search=session and chosen.search, # a session recap must not be recalled - keep_tools=True, cache_key=f"chat-{chat_id}", on_text=on_text, chat_id=chat_id, extra_tools=extra, admin=admin) return format_answer(answer.text, movers_bots=st.movers_bots, summary=trig.movers) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 21a9ba6..4c01302 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -95,7 +95,6 @@ async def test_session_question_forces_a_search(env, store): m = user_msg(ctx.bot, "@stockbot what happened at close?") await run(h, m) assert backend.seen[0]["choice"] == "required" and backend.seen[0]["search"] is True - assert backend.seen[0]["tools"] == ["yahoo__get_quote"] # quotes stay available beside the search assert "current news" in backend.seen[0]["prompt"] From 34e7f0e420b43c175c086817acae63f384af7d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:26:24 +0000 Subject: [PATCH 08/14] Session questions: ask for current news only, not prices Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/prompts.py | 6 +++--- tests/test_handlers.py | 2 +- tests/test_runner.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/prompts.py b/lib/prompts.py index 9ac8369..d36f664 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -89,9 +89,9 @@ def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str "for anything else asked, such as prices or a comparison with market data); don't reuse " "numbers or refusals from earlier replies. When comparing portfolios, or asked \"why\", " "write 2-3 short paragraphs on the tailwinds and headwinds of each portfolio, not a summary.") - if session: # a trading-session question: earlier replies and movers lists are stale by now - tail += (" Fetch current prices and current news with your tools before answering, and say why " - "each stock moved. Don't reuse numbers or reasons from earlier messages in the transcript.") + if session: # a trading-session question: news and reasons in earlier replies are stale by now + tail += (" Search for current news before answering, and say why each stock moved. Don't reuse " + "news or reasons from earlier messages in the transcript.") return head + tail + down diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 4c01302..e31610c 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -95,7 +95,7 @@ async def test_session_question_forces_a_search(env, store): m = user_msg(ctx.bot, "@stockbot what happened at close?") await run(h, m) assert backend.seen[0]["choice"] == "required" and backend.seen[0]["search"] is True - assert "current news" in backend.seen[0]["prompt"] + assert "Search for current news" in backend.seen[0]["prompt"] async def test_market_question_offers_tools_up_front(env, store): diff --git a/tests/test_runner.py b/tests/test_runner.py index 0db51a6..cb24d2d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -254,8 +254,8 @@ def test_session_question_prompt_demands_fresh_prices_and_news(): from lib.prompts import chat_prompt assert is_session_question("what happened at close?") and not is_session_question("hello") kw = dict(transcript="t", sender="A", private=False, reply_quote=None, msg_id=1, now="now") - assert "current news" in chat_prompt(**kw, session=True) - assert "current news" not in chat_prompt(**kw) + assert "Search for current news" in chat_prompt(**kw, session=True) + assert "Search for current news" not in chat_prompt(**kw) def test_gate_full_when_forced_or_token_saver_off(settings, env): From dd26bf84cdf9eee7e6bd43027aff963ddca15ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:27:46 +0000 Subject: [PATCH 09/14] Don't strip tool remarks from replies, so they stay visible Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/runner.py | 11 +++-------- lib/textfmt.py | 3 --- tests/test_textfmt.py | 7 ------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/lib/llm/runner.py b/lib/llm/runner.py index 5fbf958..cfefe87 100644 --- a/lib/llm/runner.py +++ b/lib/llm/runner.py @@ -13,7 +13,7 @@ from .. import config from ..mcp.server import Registry -from ..textfmt import LEADING_TOOL_REMARK_RE, TOOL_SYNTAX_RE +from ..textfmt import TOOL_SYNTAX_RE from .base import Answer, Backend, Call, Request, Step, Usage log = logging.getLogger("bot") @@ -28,11 +28,6 @@ TOO_MANY_SEARCHES = "Skipped: too many searches at once. Use what you already have." -def clean_text(text: str) -> str: - """The model's answer without printed tool-call syntax or a leading remark about tools.""" - return LEADING_TOOL_REMARK_RE.sub("", TOOL_SYNTAX_RE.sub("", text).strip()).strip() - - def call_args(arguments: str) -> dict: """A tool call's JSON arguments as a dict ({} if they aren't a JSON object).""" try: @@ -206,7 +201,7 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo log.info("%.1fs, %d rounds, %d tools: in %d (%.0f%% cached) out %d, $%.4f", time.monotonic() - t0, rounds, tool_calls, total.tokens_in, total.cached_pct, total.tokens_out, total.cost) - text = clean_text(step.text) + text = TOOL_SYNTAX_RE.sub("", step.text).strip() if not text and step.finish == "length" and tool_calls: # The model spent the whole cap thinking about the tool results. Keep them (answering # from memory without them invents things): ask again with more room and less thinking. @@ -216,7 +211,7 @@ async def run(backend: Backend, registry: Registry, req: Request, *, dedupe: boo step = await _step(backend, conv, more, "none") total.add(step.usage) log.info("Recovery round: in %d out %d, %s", step.usage.tokens_in, step.usage.tokens_out, step.finish) - text = clean_text(step.text) + text = TOOL_SYNTAX_RE.sub("", step.text).strip() if not text: if step.finish == "length": raise RuntimeError("hit the token cap before writing an answer") diff --git a/lib/textfmt.py b/lib/textfmt.py index 2bd0fae..fa1cffa 100644 --- a/lib/textfmt.py +++ b/lib/textfmt.py @@ -35,9 +35,6 @@ re.DOTALL | re.IGNORECASE, ) -# A model told it has no tools sometimes opens with a remark about it: "(no tool calls needed)". -LEADING_TOOL_REMARK_RE = re.compile(r"^\s*\((?:no|without)\s+(?:tool|search|lookup)[^)\n]*\)\s*", re.I) - def strip_disclaimer(text: str) -> str: """Models add these despite the prompt, and copy their own earlier ones from the transcript.""" diff --git a/tests/test_textfmt.py b/tests/test_textfmt.py index 3cf3b66..e7d8ba4 100644 --- a/tests/test_textfmt.py +++ b/tests/test_textfmt.py @@ -19,13 +19,6 @@ def test_tool_syntax_does_not_eat_the_answer(): assert t.TOOL_SYNTAX_RE.sub("", "").strip() == "" -def test_leading_tool_remark_is_stripped(): - from lib.llm.runner import clean_text - assert clean_text(" (no tool calls needed)Wasion (3393) -6%") == "Wasion (3393) -6%" - assert clean_text("(No search needed) Fine.") == "Fine." - assert clean_text("Wasion (no tool calls needed) fell") == "Wasion (no tool calls needed) fell" - - def test_tg_tag_regex_keeps_bare_lt(): s = lambda x: t.TG_TAG_RE.sub("", x) # noqa: E731 assert s("NVDA <5% ok x") == "NVDA <5% ok x" From 9b01102474eb337f2062e7deb8e658a876152c2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:31:12 +0000 Subject: [PATCH 10/14] Rename session prompt test Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- tests/test_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_runner.py b/tests/test_runner.py index cb24d2d..5c6e16d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -249,7 +249,7 @@ def test_gate_market_session_words_are_live(text, settings): assert r.search and not r.simple -def test_session_question_prompt_demands_fresh_prices_and_news(): +def test_session_question_prompt_demands_current_news(): from lib.llm.gate import is_session_question from lib.prompts import chat_prompt assert is_session_question("what happened at close?") and not is_session_question("hello") From 8e880086083bcc66900605231b7f6e60e8765c20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 22:32:17 +0000 Subject: [PATCH 11/14] Don't tell the model to avoid remarks about tools Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/prompts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/prompts.py b/lib/prompts.py index d36f664..dcfda7a 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -24,12 +24,12 @@ NO_SEARCH_NOTE = "You have no web search: say plainly what you can't check instead of guessing.\n" SIMPLE_NOTE = ("You have no live data tools for this message. If answering needs live prices, " f"news or portfolio data, reply with exactly {NEEDS_TOOLS} and nothing else. Never claim " - "to have looked something up, and never remark on tools or what you did or didn't use.\n") + "to have looked something up.\n") PARTIAL_NOTE = ("If you need a data source or search you don't have this time, reply with exactly " f"{NEEDS_TOOLS} and nothing else.\n") NO_TOOLS_NOTE = ("You have no web access and no tools this time. Answer from the chat history and " "what you know, say plainly what you can't check, and never output tool-call " - "syntax or JSON as text, or any remark about tools.\n") + "syntax or JSON as text.\n") def tools_note(servers: list[MCPServer]) -> str: From 08f8695e6da1ed9821331d1f44cf4411a0cd1ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:02:30 +0000 Subject: [PATCH 12/14] xAI: keep only the last text part of a message, and log multi-part messages Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/xai.py | 15 +++++++++++---- tests/test_llm_backends.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/llm/xai.py b/lib/llm/xai.py index 7e5c6b9..51cf2c6 100644 --- a/lib/llm/xai.py +++ b/lib/llm/xai.py @@ -34,14 +34,19 @@ def _as_input(item) -> dict: def answer_text(resp) -> str: - """The response's last message. `output_text` joins every message, and with server-side search - the model can write a draft, search, then write the answer, all in one response.""" + """The response's last message, and in it the last text part. `output_text` joins everything, + and with server-side search the model can write a draft, search, then write the answer, as + separate messages or as separate parts of one.""" messages = [i for i in resp.output if i.type == "message"] - texts = ["".join(getattr(c, "text", "") or "" for c in m.content if c.type == "output_text") - for m in messages] + parts = [[getattr(c, "text", "") or "" for c in m.content if c.type == "output_text"] for m in messages] + texts = [next((t for t in reversed(p) if t.strip()), "") for p in parts] if len([t for t in texts if t.strip()]) > 1: log.info("xAI response had %d messages; kept the last (items: %s)", len(texts), " ".join(i.type for i in resp.output)) + for p in parts: + if len([t for t in p if t.strip()]) > 1: + log.info("xAI message had %d text parts (%s chars); kept the last", + len(p), "+".join(str(len(t)) for t in p)) return next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip() @@ -88,6 +93,8 @@ async def _create(self, req: Request, kw: dict, **inputs): async for event in stream: if event.type == "response.output_item.added" and event.item.type == "message": text = "" # a new message replaces the draft before it + elif event.type == "response.content_part.added" and event.part.type == "output_text": + text = "" # a new text part replaces the draft before it elif event.type == "response.output_text.delta": text += event.delta if req.on_text: diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index 79a25b2..b5c48a4 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -247,6 +247,25 @@ async def test_xai_keeps_only_the_last_message_when_it_drafts_before_searching() assert seen == ["draft", "answer"] +async def test_xai_keeps_only_the_last_text_part_of_one_message(): + from types import SimpleNamespace as N + st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) + message = N(type="message", content=[N(type="output_text", text="draft. One blank line after each."), + N(type="output_text", text="answer")]) + events = [N(type="response.output_item.added", item=message), + N(type="response.content_part.added", part=message.content[0]), + N(type="response.output_text.delta", delta="draft. One blank line after each."), + N(type="response.content_part.added", part=message.content[1]), + N(type="response.output_text.delta", delta="answer"), + xai_final([message], text="draft. One blank line after each.answer")] + seen = [] + b = XaiBackend(st, FakeXaiClient(events)) + r = req(on_text=seen.append) + step = await b.step(b.start(r), r, tool_choice=None) + assert step.text == "answer" + assert seen == ["draft. One blank line after each.", "answer"] + + async def test_xai_failed_response_at_capacity_is_overloaded_other_failures_are_not(): from types import SimpleNamespace as N st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) From b6fffa023337479e18310d39e9a821ec8901cfa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:18:38 +0000 Subject: [PATCH 13/14] Revert session changes (built on stale feedback) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/gate.py | 9 +-------- lib/llm/xai.py | 15 ++++----------- lib/prompts.py | 5 +---- lib/telegram/handlers.py | 12 ++++-------- tests/test_handlers.py | 8 -------- tests/test_llm_backends.py | 19 ------------------- tests/test_runner.py | 16 ---------------- 7 files changed, 10 insertions(+), 74 deletions(-) diff --git a/lib/llm/gate.py b/lib/llm/gate.py index 6f05d10..b80dbc0 100644 --- a/lib/llm/gate.py +++ b/lib/llm/gate.py @@ -34,9 +34,6 @@ LIVE_WORDS = re.compile( r"\b(news|today|latest|yesterday|tonight|this week|right now|breaking|announce\w*|who won|" r"score|weather|happening|headlines?|current\w*|updates?|recent\w*|just (now|in))\b", re.I) -# Asking about a trading session ("what happened at close"): answers must be fetched, not recalled. -SESSION_WORDS = re.compile( - r"\b(?:(?:at|the|into|after) (?:the )?(?:close|open)|pre-?market|after[- ]hours|closing|opening)\b", re.I) @dataclass(frozen=True) @@ -68,10 +65,6 @@ def is_portfolio_question(text: str, st: Settings) -> bool: return bool(PORTFOLIO_WEAK.search(text)) and not _has_ticker(text) -def is_session_question(text: str) -> bool: - return bool(SESSION_WORDS.search(text)) - - def route(text: str, st: Settings, registry: Registry, *, force_full: bool = False) -> Route: """Tools for this message. With TOKEN_SAVER off, or `force_full` (a search/data request such as a movers list, a preset button or a retry after NEEDS_TOOLS), everything is offered.""" @@ -80,7 +73,7 @@ def route(text: str, st: Settings, registry: Registry, *, force_full: bool = Fal return Route(up, st.search) portfolio = is_portfolio_question(text, st) market = portfolio or bool(MARKET_WORDS.search(text)) or _has_ticker(text) - live = is_session_question(text) or bool(LIVE_WORDS.search(text)) + live = bool(LIVE_WORDS.search(text)) servers = [s for s in up if (portfolio if s.cfg.get("gate") == "portfolio" else market)] search = st.search and (market or live) simple = not (servers or search) diff --git a/lib/llm/xai.py b/lib/llm/xai.py index 51cf2c6..7e5c6b9 100644 --- a/lib/llm/xai.py +++ b/lib/llm/xai.py @@ -34,19 +34,14 @@ def _as_input(item) -> dict: def answer_text(resp) -> str: - """The response's last message, and in it the last text part. `output_text` joins everything, - and with server-side search the model can write a draft, search, then write the answer, as - separate messages or as separate parts of one.""" + """The response's last message. `output_text` joins every message, and with server-side search + the model can write a draft, search, then write the answer, all in one response.""" messages = [i for i in resp.output if i.type == "message"] - parts = [[getattr(c, "text", "") or "" for c in m.content if c.type == "output_text"] for m in messages] - texts = [next((t for t in reversed(p) if t.strip()), "") for p in parts] + texts = ["".join(getattr(c, "text", "") or "" for c in m.content if c.type == "output_text") + for m in messages] if len([t for t in texts if t.strip()]) > 1: log.info("xAI response had %d messages; kept the last (items: %s)", len(texts), " ".join(i.type for i in resp.output)) - for p in parts: - if len([t for t in p if t.strip()]) > 1: - log.info("xAI message had %d text parts (%s chars); kept the last", - len(p), "+".join(str(len(t)) for t in p)) return next((t for t in reversed(texts) if t.strip()), resp.output_text or "").strip() @@ -93,8 +88,6 @@ async def _create(self, req: Request, kw: dict, **inputs): async for event in stream: if event.type == "response.output_item.added" and event.item.type == "message": text = "" # a new message replaces the draft before it - elif event.type == "response.content_part.added" and event.part.type == "output_text": - text = "" # a new text part replaces the draft before it elif event.type == "response.output_text.delta": text += event.delta if req.on_text: diff --git a/lib/prompts.py b/lib/prompts.py index dcfda7a..d9cb3da 100644 --- a/lib/prompts.py +++ b/lib/prompts.py @@ -69,7 +69,7 @@ def down_note(down: list[MCPServer]) -> str: def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str | None, msg_id: int, now: str, down: str = "", saver: bool = True, middle: str = "", - fresh: bool = False, session: bool = False) -> str: + fresh: bool = False) -> str: """The user turn: transcript, the replied-to message, and the volatile tail (kept last so the cached prefix before it is unchanged).""" head = (f"Private one-to-one chat with {sender}, not the group. Transcript:\n{transcript}" @@ -89,9 +89,6 @@ def chat_prompt(*, transcript: str, sender: str, private: bool, reply_quote: str "for anything else asked, such as prices or a comparison with market data); don't reuse " "numbers or refusals from earlier replies. When comparing portfolios, or asked \"why\", " "write 2-3 short paragraphs on the tailwinds and headwinds of each portfolio, not a summary.") - if session: # a trading-session question: news and reasons in earlier replies are stale by now - tail += (" Search for current news before answering, and say why each stock moved. Don't reuse " - "news or reasons from earlier messages in the transcript.") return head + tail + down diff --git a/lib/telegram/handlers.py b/lib/telegram/handlers.py index 8ab1550..b4f669e 100644 --- a/lib/telegram/handlers.py +++ b/lib/telegram/handlers.py @@ -18,7 +18,7 @@ from ..features import dm_buttons, holding_news, movers, periods, post from ..history import compact, format_rows from ..llm.base import BUSY_REPLY, is_busy -from ..llm.gate import Route, is_portfolio_question, is_session_question, route +from ..llm.gate import Route, is_portfolio_question, route from ..msgtext import describe, sender_name from ..prompts import MOVERS_EXPLAIN, PRESET_PROMPT, chat_prompt, down_note from ..store import is_dm @@ -179,8 +179,7 @@ async def _gate_text(self, msg: Message, trig: Trigger) -> str: text += " " + compact(prev.text)[:FOLLOW_UP_CHARS] return text - async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False, - session: bool = False) -> str: + async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: bool = False) -> str: ctx, st = self.ctx, self.ctx.st rows = await asyncio.to_thread(ctx.store.history, msg.chat_id, st.history_limit) transcript = format_rows(rows, ctx.self_name, st.tz, line_max=st.history_line_max, @@ -188,8 +187,7 @@ async def _chat_text(self, msg: Message, trig: Trigger, middle: str = "", fresh: return chat_prompt( transcript=transcript, sender=sender_name(msg), private=trig.private, reply_quote=self._quote(trig), msg_id=msg.message_id, now=ctx.now(), - down=down_note(ctx.registry.down()), saver=st.token_saver, middle=middle, fresh=fresh, - session=session) + down=down_note(ctx.registry.down()), saver=st.token_saver, middle=middle, fresh=fresh) # -- answering --------------------------------------------------------------------- async def _answer(self, msg: Message, trig: Trigger) -> None: @@ -259,11 +257,9 @@ async def _generate(self, msg: Message, trig: Trigger, draft: Draft | None) -> s if chosen.simple: # the model must not be told it has no tools chosen = Route([], False) fresh = is_portfolio_question(context, st) and any(s.cfg.get("gate") == "portfolio" for s in chosen.servers) - session = is_session_question(context) and not chosen.simple - parts = [{"type": "text", "text": await self._chat_text(msg, trig, fresh=fresh, session=session)}] + parts = [{"type": "text", "text": await self._chat_text(msg, trig, fresh=fresh)}] parts += await self._images(msg, trig) answer = await ask(ctx, parts, chosen, - must_search=session and chosen.search, # a session recap must not be recalled cache_key=f"chat-{chat_id}", on_text=on_text, chat_id=chat_id, extra_tools=extra, admin=admin) return format_answer(answer.text, movers_bots=st.movers_bots, summary=trig.movers) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e31610c..ba7f5a5 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -90,14 +90,6 @@ async def test_partial_route_that_needs_more_is_asked_again_with_everything(env, assert "NEEDS_TOOLS" in backend.seen[0]["system"] # the partial route told the model how to ask -async def test_session_question_forces_a_search(env, store): - ctx, backend, h = make_ctx(env, store, [step("fresh recap", searches=1)], servers=[FakeMcp("yahoo")]) - m = user_msg(ctx.bot, "@stockbot what happened at close?") - await run(h, m) - assert backend.seen[0]["choice"] == "required" and backend.seen[0]["search"] is True - assert "Search for current news" in backend.seen[0]["prompt"] - - async def test_market_question_offers_tools_up_front(env, store): mcp = FakeMcp("yahoo") ctx, backend, h = make_ctx(env, store, [step("ok")], servers=[mcp]) diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index b5c48a4..79a25b2 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -247,25 +247,6 @@ async def test_xai_keeps_only_the_last_message_when_it_drafts_before_searching() assert seen == ["draft", "answer"] -async def test_xai_keeps_only_the_last_text_part_of_one_message(): - from types import SimpleNamespace as N - st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) - message = N(type="message", content=[N(type="output_text", text="draft. One blank line after each."), - N(type="output_text", text="answer")]) - events = [N(type="response.output_item.added", item=message), - N(type="response.content_part.added", part=message.content[0]), - N(type="response.output_text.delta", delta="draft. One blank line after each."), - N(type="response.content_part.added", part=message.content[1]), - N(type="response.output_text.delta", delta="answer"), - xai_final([message], text="draft. One blank line after each.answer")] - seen = [] - b = XaiBackend(st, FakeXaiClient(events)) - r = req(on_text=seen.append) - step = await b.step(b.start(r), r, tool_choice=None) - assert step.text == "answer" - assert seen == ["draft. One blank line after each.", "answer"] - - async def test_xai_failed_response_at_capacity_is_overloaded_other_failures_are_not(): from types import SimpleNamespace as N st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"}) diff --git a/tests/test_runner.py b/tests/test_runner.py index 5c6e16d..17b3d38 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -242,22 +242,6 @@ def test_gate_live_question_gets_search_only(settings): assert r.search and not r.servers -@pytest.mark.parametrize("text", ["what happened at close?", "how did it go at the open", "any pre-market moves"]) -def test_gate_market_session_words_are_live(text, settings): - y, s = servers() - r = route(text, settings, registry(y, s)) - assert r.search and not r.simple - - -def test_session_question_prompt_demands_current_news(): - from lib.llm.gate import is_session_question - from lib.prompts import chat_prompt - assert is_session_question("what happened at close?") and not is_session_question("hello") - kw = dict(transcript="t", sender="A", private=False, reply_quote=None, msg_id=1, now="now") - assert "Search for current news" in chat_prompt(**kw, session=True) - assert "Search for current news" not in chat_prompt(**kw) - - def test_gate_full_when_forced_or_token_saver_off(settings, env): y, s = servers() assert route("hello", settings, registry(y, s), force_full=True).servers == [y, s] From 12fe926abf09be431a0cb031ca9dda60852d99ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:19:01 +0000 Subject: [PATCH 14/14] xAI: log a message that has several text parts Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014k4kywyN5dAo39izUPvxbw --- lib/llm/xai.py | 7 +++++-- tests/test_llm_backends.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/llm/xai.py b/lib/llm/xai.py index 7e5c6b9..477514d 100644 --- a/lib/llm/xai.py +++ b/lib/llm/xai.py @@ -37,8 +37,11 @@ def answer_text(resp) -> str: """The response's last message. `output_text` joins every message, and with server-side search the model can write a draft, search, then write the answer, all in one response.""" messages = [i for i in resp.output if i.type == "message"] - texts = ["".join(getattr(c, "text", "") or "" for c in m.content if c.type == "output_text") - for m in messages] + parts = [[getattr(c, "text", "") or "" for c in m.content if c.type == "output_text"] for m in messages] + texts = ["".join(p) for p in parts] + for p in parts: + if len([t for t in p if t.strip()]) > 1: # not handled yet: this line tells us if it happens + log.info("xAI message had %d text parts (%s chars)", len(p), "+".join(str(len(t)) for t in p)) if len([t for t in texts if t.strip()]) > 1: log.info("xAI response had %d messages; kept the last (items: %s)", len(texts), " ".join(i.type for i in resp.output)) diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index 79a25b2..f205dd5 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -247,6 +247,17 @@ async def test_xai_keeps_only_the_last_message_when_it_drafts_before_searching() assert seen == ["draft", "answer"] +async def test_xai_logs_a_message_with_several_text_parts_and_still_joins_them(caplog): + from types import SimpleNamespace as N + + from lib.llm.xai import answer_text + caplog.set_level("INFO", logger="bot") + message = N(type="message", content=[N(type="output_text", text="one"), N(type="output_text", text="two")]) + assert answer_text(N(output=[message], output_text="onetwo")) == "onetwo" + assert [r.getMessage() for r in caplog.records if "text parts" in r.getMessage()] == [ + "xAI message had 2 text parts (3+3 chars)"] + + async def test_xai_failed_response_at_capacity_is_overloaded_other_failures_are_not(): from types import SimpleNamespace as N st = config.load({"TELEGRAM_BOT_TOKEN": "1:x", "XAI_API_KEY": "k"})