From d2a8d716ba087bcf15c692ed5c2f0e8925a885bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:42:07 +0000 Subject: [PATCH 01/10] /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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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: