diff --git a/.env.example b/.env.example index be8e247..c691261 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,13 @@ # (Qwen, Gemini, Grok, Mistral, GLM, ChatGPT, Claude, Kimi). BROWSER_BRIDGE_ENABLED=0 +# Provider error blocking (default 1). When 1, durable pause and unresolved +# pending attempts block future requests until `python -m providers.access resume `. +# When 0, stored provider pauses/pending blocks are ignored and new failures are +# not latched; this does not disable provider quotas, local budgets, or chat-history +# consistency checks, and may retry uncertain requests / duplicate provider work. +PROVIDER_ACCESS_PAUSE=1 + # How long to wait for one provider answer, in seconds (1–18000). Default 600. # Used by browser tabs, Kimi HTTP and Gemini CLI (after a browser stream starts, # the same budget applies). @@ -40,6 +47,17 @@ PROVIDER_MIN_INTERVAL=30 # and one-use continuation protection. CONVERSATION_MAX_REPLIES=20 +# Repeated initial prompts with exactly identical history produce the same cache key. +# The bridge does not open a new provider chat while a matching link is retained; it may +# reuse that link if still unused, or reject the repeat when already consumed. +# On startup, optionally delete closed/consumed links so a repeated initial prompt +# can start a new chat: 0 (default) keeps them; 1 deletes rows with used=1 only; +# unused links (used=0) remain. The same prompt may then create a new upstream chat, +# and its later continuation may bind to the wrong chat and fail. +# For a complete manual cleanup of all chat-cache tables, run: +# python -m server.conversation_admin -clear +CONVERSATION_CLEAR_USED_ON_STARTUP=0 + # Opt-in: one automatic context migration per failed request, in the SAME # Harness goal. Only an empty, explicitly cleared context refusal qualifies. # Private checkpoints in session/context-checkpoints; no counter reset/replay. @@ -50,27 +68,50 @@ DEEPSEEK_CONTEXT_ROLLOVER=0 # RATE_LIMIT_PER_MINUTE=30 # per-client-IP limit on /v1 endpoints # --- TOOL / TOOLCALL --- +# Optional raw per-chat traces (prompts, tool calls/results and replies) in ./llog/. +# Files can contain private project data. Disabled by default. +TOOLCALL_LOG=0 + # How OpenAI `tools` are flattened into the provider prompt. # full — full JSON Schema + tool_calls rules on every turn. # light — recommended; full block on the first turn of a provider chat; short # name/format reminder on continuations (smaller follow-ups). +# medium — like light, but the continuation reminder also lists each tool's +# argument shape (field names, nesting, `?` optional; no descriptions), so +# a model does not guess arguments from memory. Longer follow-up prompts. # none — like light on the first turn; no tools block on continuations. # HTTP transports (DeepSeek, Qwen API, Kimi direct, Gemini CLI): default full. # Browser tab bridge (grok/mistral/glm/chatgpt/claude/kimi/qwen/gemini): default light. TOOL_INSTRUCTIONS_HTTP=full TOOL_INSTRUCTIONS_BROWSER=light -# DeepSeek's web output limit can cut a large tool call off mid-JSON. When that -# happens it stops without returning partial calls. Continuations are off by -# default. An explicit opt-in (>0) only continues a provider-confirmed output -# limit, never an otherwise finished reply with a missing Markdown fence. +# Temporary: which tool contract to use for every provider. +# 1 (default) — main-era in api.py + tool_protocol.py (single-block validate, +# DeepSeek-only format repair, always invalid_tool_response). Will be removed. +# 0 — tool-rework in api_new.py + tool_protocol_new.py for all providers +# (demote, multi-block, pointed repair, INVALID_MODE). +TOOLCALL_LEGACY=1 + +# Provider output limit can cut a tool call off mid-JSON. Continuations are off +# by default. With TOOLCALL_LEGACY=0 an opt-in (>0) continues when a top-level +# tool_calls fence is still open anywhere in the reply; with LEGACY=1 only when +# the whole reply starts with an open fence (main-era). Never continues a normal +# stop that merely omitted a Markdown fence. TOOLCALL_MAX_CONTINUATIONS=0 -# Optional: one format correction after a normally completed invalid DeepSeek -# tool reply, in the same conversation. Adds at most one paced request. Never -# retries provider refusals, output limits, or uncertain dispatches. Default off. +# Optional: one format correction after a normally completed erroneous tool reply. +# LEGACY=1: DeepSeek only (main-era prompt). LEGACY=0: every provider (catalog +# on unknown tool, argument shape on schema). Default off. TOOLCALL_FORMAT_REPAIR=0 +# Outcome after an invalid tool reply (or a failed single format correction). +# Only when TOOLCALL_LEGACY=0 (ignored under LEGACY=1 — always error). +# error — invalid_tool_response (default; fail-closed) +# text — live provider chat + explicit failed step for the agent: +# content notice (BRIDGE_TOOL_NOT_EXECUTED), top-level bridge_notice, +# message.refusal; next user turn → provider as Tool result (name): … +TOOLCALL_INVALID_MODE=error + # Dump the raw model reply to the log whenever a tool call can't be parsed. # TOOLCALL_DEBUG=1 diff --git a/README.en.md b/README.en.md index f1d763e..5af1fd5 100644 --- a/README.en.md +++ b/README.en.md @@ -237,7 +237,93 @@ its context. It is not a local budget or expired session; resetting counters or signing in again will not fix it. The bridge preserves history and stops, without creating a new chat. Migrating the goal requires a separate manual decision. -Disabling `TOOLCALL_FORMAT_REPAIR` prevents an extra format-correction generation; +Optional `TOOLCALL_LOG=1` enables detailed conversation traces in `logs/` (default +`0`, disabled). Each conversation gets one file named `MMDDHHmmss-.log`; +it accumulates agent requests, assembled provider prompts, raw provider replies, +final JSON/SSE responses to the agent, and errors, separated by `from agent`, `to provider`, +`from provider`, and `to agent` markers. Logs can contain source code, secrets and +tool results; enable only for local diagnosis and do not publish them. Git ignores +the directory. Implementation checks are offline; live-account exchanges were not +verified. Continuations are associated via the persisted conversation ID; if a +provider returns no ID, a later request cannot be linked to this trace. + +New mode `TOOL_INSTRUCTIONS_BROWSER=medium` (the default stays `light`, unchanged: +tool names only). On chat continuations the reminder also includes each tool's +argument shape (field names, nesting, `?` = optional; no descriptions) so the model +does not guess arguments from memory; the first turn still gets the full block, as +in `light`. Continuation prompts are longer. A reply like `{"question": ..., "options": [...]}` instead of `{"questions": [...]}` +is rejected with "Tool arguments do not satisfy the advertised schema". No extra +generation. + +Temporary switch `TOOLCALL_LEGACY` (default `1`): `1` — main-era contract for +**every** provider (`server/api.py` + `server/tool_protocol.py`: one block, +DeepSeek-only repair, always `invalid_tool_response`; will be removed); `0` — one +tool-rework path in `server/api_new.py` + `server/tool_protocol_new.py` for **every** +provider, including DeepSeek (demote, multi-block, pointed repair, +`TOOLCALL_INVALID_MODE`). Summary in +[SETUP §5](docs/SETUP.en.md#5-why-the-model-doesnt-make-edits-emulated-tool-calling); +longer notes (Russian): +[`docs/toolcall-format-repair-legacy.md`](docs/toolcall-format-repair-legacy.md) +(`LEGACY=1`) and +[`docs/toolcall-format-repair.md`](docs/toolcall-format-repair.md) (`LEGACY=0`). +With `TOOLCALL_LEGACY=0`, tool calls are handled like in any agent. Only a top-level +fenced `tool_calls` block is a call, wherever it stands in the message. Prose, text +after the block, several blocks, arrays without that fence (also inside a +```` ```json ```` block) and quoted blocks are plain text. Before validate/repair the +bridge may demote advice mistagged as a call: if a block has no call-level `name` +(JSON whole or broken) or the name is unknown and there is accompanying prose, the +opening fence is relabeled from `tool_calls` to `text` and the agent gets ordinary +successful `content` — no repair and no `TOOLCALL_INVALID_MODE`. Otherwise every +remaining block must be valid: broken JSON (including a known tool name), an unknown +name with no prose, or arguments off-schema stop the whole message (repair / +`TOOLCALL_INVALID_MODE`); there is no silent validate pass. When every block is +valid, all calls run in order: the agent receives them as `tool_calls` and the rest +of the message, with the call blocks cut out, as ordinary content. +`tool_choice: none` never turns text into an error. Verified offline. + +With `TOOLCALL_FORMAT_REPAIR=1` (off by default), under `LEGACY=0` an erroneous call +gets one pointed correction in the same chat (one extra generation): for an unknown +tool the full list of tools with their argument shapes (field names, nesting, `?` = +optional; not shortened), for wrong arguments that tool's argument shape (no values). +No second correction is requested. If the corrected reply is valid the agent sees only +the final call, with no notice. What happens next is `TOOLCALL_INVALID_MODE` (below). +Under `LEGACY=1`, repair remains DeepSeek-only (main-era prompt). Verified offline. + +`TOOLCALL_INVALID_MODE` (default `error`; only when `LEGACY=0`): what to do with an +invalid call after validation or after one failed correction — for **any** provider, +including DeepSeek. With `error` the agent gets +`invalid_tool_response` (fail-closed). With `text` — HTTP success and a live provider +chat, but an explicit failed tool step for the agent: + +- `content`: raw reply plus `Bridge notice: BRIDGE_TOOL_NOT_EXECUTED: …` (tool name at + most; no schemas or values); +- top-level `bridge_notice` (`executed: false`, `code`, `kind`, `tool`, `as_tool_error` + shaped like a tool-role error); +- `message.refusal`: the same notice text (for clients that honor refusal); +- the next user message is sent to the provider tab as + `Tool result (name): ` plus the follow-up — as if the tool returned an error. + +Executable `tool_calls` are not invented on reject (the agent would run them). Even with +`text`, these still error: `tool_choice` required/specific tool, a reply without a +conversation id, and an unfinished reply. Mistagged advice (no call-level name, or +unknown name with prose) is demoted to a `text` fence before this path. +Verified offline. + +A call that repeats, with identical arguments, the call whose result the request +carries (the model answers a tool result with the same call again) is not executed a +second time: with `TOOLCALL_INVALID_MODE=text` the agent gets the same kind of message +(raw reply plus a line saying the call repeats the one just executed); with `error` it +raises. A different call, or a call after a new user message, runs normally. Verified +offline. + +The tool result the agent returns (for example the option you picked) goes to the +provider as just `Tool result (name): `. It carries no tools reminder +(`TOOL_INSTRUCTIONS_BROWSER` still applies to ordinary user messages), no repeated +question or call and no bridge call id; results of several calls to the same tool keep +the identifier mapping. Invalid-call outcome follows `TOOLCALL_INVALID_MODE` (above). +Verified offline. + +For DeepSeek, disabling `TOOLCALL_FORMAT_REPAIR` prevents an extra format-correction generation; it does not disable tool-markup validation. Malformed tool blocks stop the step instead of being returned as successful plain text. After a normal DeepSeek stop, commentary may precede one explicit terminal @@ -251,9 +337,10 @@ fences and quotations do not become calls. Prose quotations may precede a separa top-level `tool_calls` block with a blank line before its opening fence. Quoted, nested and ambiguous code blocks are not executed; `>` markers are never stripped from code. An explicit `tool_calls` block is an -action protocol: do not use it for explanatory examples. Remote schemas are never -fetched; local `$ref` references work. On update, install `requirements.txt` -(the new dependency is `jsonschema`) and restart the bridge. +action protocol: do not use it for explanatory examples. Browser providers follow the +rules above instead. Remote schemas are never fetched; local `$ref` references work. +On update, install `requirements.txt` (the new dependency is `jsonschema`) and +restart the bridge. A normal stop with a closed fence also permits swapping the final `]}` to `}]` only when a string-aware scan confirms that arguments are complete and only the @@ -422,7 +509,10 @@ DeepSeek defaults are **12/60 generations, 3/6 new chats and 40/200 HTTP request per hour/day**, with a shared 20 accepted replies per conversation (`CONVERSATION_MAX_REPLIES`) and a 30-second interval. `DEEPSEEK_MAX_*` budget ceilings and `CONVERSATION_MAX_REPLIES` belong to the bridge process environment. -Raising a local ceiling does not raise the website's allowance. +Raising a local ceiling does not raise the website's allowance. `PROVIDER_ACCESS_PAUSE=0` +disables provider pause and pending blocks, including persisted ones; it does not +bypass budgets, provider quotas or conversation-history safeguards, and can allow +retries after an uncertain outcome. [Configuration and error guide](docs/HARNESS.en.md#local-limits-and-errors). ## Connect OpenCode @@ -431,6 +521,8 @@ Use the same bridge and account setup above. Put the example [`opencode.json`](examples/opencode.json) in your project. For existing configs, merge settings while preserving your `provider`, `model` and `small_model`. Set `small_model` explicitly: title generation can also consume messages. +Do not point title/`small_model` at a browser model — that opens a second tab +chat (Harness disables `session-title-llm` in its profile). ```bash cd /path/to/your-project @@ -474,7 +566,9 @@ and reset time have not been established. **Harness or OpenCode runs tools on your computer.** Prompts and tool-read data are sent to the selected provider. The bridge validates responses and `tool_calls`, saves conversation state and sends new tool results to the existing -chat. Unknown history stops instead of silently creating a new chat. +chat. Continuation matching ignores client `system` prompts and OpenCode +`` wrappers (they change between turns). Unknown or already +consumed history stops instead of silently creating a new chat. Refusal pauses survive restart. Client setup, ordinary sign-in and local budget reset do not clear them. Safari transfer also preserves the entire pause, pending @@ -495,6 +589,14 @@ the tab, not to an authenticated answer source. DeepSeek/Qwen streams stop after 300 seconds total or 15 seconds without bytes; such a stop pauses the provider and is not replayed. A first request (no assistant message yet) has no history link, so resending it can open a second chat; continuations are one-use. +Set `CONVERSATION_CLEAR_USED_ON_STARTUP=1` in the bridge process environment to +delete consumed/closed history links at startup; the default `0` preserves them. +A repeated first request with exactly identical history otherwise has the same +cache key, so the bridge will not open another provider chat for it while the link +is retained. It may reuse an unused link or reject an already-consumed one. Deleting +used links allows a repeat to start another upstream chat, +but a later continuation may bind to the wrong chat and fail. Links with +`used=0` are not deleted. ## OpenCode demonstrations @@ -507,6 +609,18 @@ These are **not Harness screenshots** or proof of current account availability. [GLM/Kimi captures and recording method](docs/media/README.md). +A repeated initial request with **exactly identical history** has the same cache key. +While its link is retained, the bridge does not open a new provider chat for that +history: it may reuse a still-unused link or reject a repeat whose link was already +consumed. By default consumed/closed links are retained to avoid binding a repeat to +another provider chat. With `CONVERSATION_CLEAR_USED_ON_STARTUP=1`, startup deletes +only consumed links (`used=1`), so a repeated prompt may create a new upstream chat; +a later continuation may then bind to the wrong chat. For a complete manual +cleanup, run `python -m server.conversation_admin -clear`: it deletes all `links`, +associated log-path mappings (`trace_links`), and migration records (`rollovers`) +from `session/deepseek-conversations.sqlite3`. It does not delete log files or chats +at the provider. This operation is destructive. + ## Documentation and development - [Harness: setup, sessions, budgets, offline verification](docs/HARNESS.en.md) @@ -526,7 +640,7 @@ Claude requires a network terminal marker; truncated, partial or malformed answe not reported as complete. Uncertain dispatched outcomes retain pending state and a safety pause across restart. There is no automatic retry. Verified offline without live accounts. Re-export and update the userscripts -to apply the browser changes (controller 0.3.34). +to apply the browser changes (controller 0.3.35). Mistral accepts only a request-bound stream with a terminal trailer. Harness `--without-browser` excludes browser models and their aliases. diff --git a/README.md b/README.md index 6d0d2e4..2681a36 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,96 @@ HTTP-адрес loopback без учётных данных, пути, query и не помогут. Мост сохраняет историю и останавливается; новый чат автоматически не создаётся. Перенос цели в новый диалог требует отдельного ручного решения. -Отключение `TOOLCALL_FORMAT_REPAIR` запрещает дополнительную генерацию для +Необязательное `TOOLCALL_LOG=1` включает подробную запись бесед в `logs/` +(по умолчанию `0`, выключено). На один диалог создаётся один файл +`MMDDHHmmss-<провайдер>.log`: в него добавляются запросы агента, собранные prompt-ы, +сырые ответы провайдера, итоговые JSON/SSE-ответы агенту и ошибки — с разделителями +`from agent`, `to provider`, `from provider`, `to agent`. Логи могут содержать исходный +код, секреты и результаты инструментов; включайте только для локальной диагностики, +не публикуйте их. Папка игнорируется Git. Проверка реализации — офлайн; обмены с +живыми аккаунтами не проверялись. Для продолжения используется сохранённый ID диалога; +если провайдер не вернул ID, следующая беседа не может быть связана с этим файлом. + +Новый режим `TOOL_INSTRUCTIONS_BROWSER=medium` (по умолчанию остаётся `light`, он +прежний: только имена инструментов). На продолжениях беседы напоминание дополнительно +содержит форму аргументов каждого инструмента (имена полей, вложенность, `?` — +необязательное; без описаний), чтобы модель не придумывала аргументы по памяти; +первый ход, как и в `light`, получает полный блок. Запросы на продолжениях при этом +длиннее. Ответ вроде +`{"question": ..., "options": [...]}` вместо `{"questions": [...]}` отклоняется как +«Tool arguments do not satisfy the advertised schema». + +Временный переключатель `TOOLCALL_LEGACY` (по умолчанию `1`): `1` — контракт как +на main для **всех** провайдеров (`server/api.py` + `server/tool_protocol.py`: +один блок, repair только DeepSeek, всегда `invalid_tool_response`; будет удалён); +`0` — единый путь tool-rework в `server/api_new.py` + `server/tool_protocol_new.py` +для **всех** провайдеров, включая DeepSeek (demote, multi-block, pointed repair, +`TOOLCALL_INVALID_MODE`). Сводка — в +[SETUP §5](docs/SETUP.ru.md#5-почему-модель-не-вносит-правки-эмуляция-tool-calling); +развёрнуто: +[`docs/toolcall-format-repair-legacy.md`](docs/toolcall-format-repair-legacy.md) +(`LEGACY=1`) и +[`docs/toolcall-format-repair.md`](docs/toolcall-format-repair.md) (`LEGACY=0`). +При `TOOLCALL_LEGACY=0` вызовы обрабатываются как в любом агенте. Вызовом является +только верхнеуровневый блок `tool_calls` в fence, где бы он ни стоял в сообщении. +Проза, текст после блока, несколько блоков, массивы без этого fence (в том числе в +блоке ```` ```json ````) и цитируемые блоки остаются обычным текстом. До +validate/repair мост может сбросить «совет под видом вызова»: если у блока нет +call-level `name` (JSON целый или битый) или имя неизвестно и рядом есть проза — +открывающий fence переименовывается из `tool_calls` в `text`, агент получает +обычный успешный `content`, без repair и без `TOOLCALL_INVALID_MODE`. Иначе каждый +оставшийся блок должен быть корректным: битый JSON (в т.ч. с известным именем), +неизвестное имя без прозы или аргументы не по схеме останавливают всё сообщение +(repair / `TOOLCALL_INVALID_MODE`); silent pass в validate больше нет. Если все +блоки корректны, все вызовы выполняются по порядку: агент получает их как +`tool_calls`, а остальной текст, с вырезанными блоками, как обычное содержимое. +`tool_choice: none` никогда не превращает текст в ошибку. Проверено офлайн. + +С `TOOLCALL_FORMAT_REPAIR=1` (по умолчанию выключено) при `LEGACY=0` ошибочный +вызов получает одну pointed-коррекцию в той же беседе (одна дополнительная +генерация): для неизвестного инструмента — полный список инструментов с формой +аргументов (имена полей, вложенность, `?` — необязательное; без сокращений), для +неверных аргументов — форма аргументов этого инструмента (без значений). Вторая +коррекция не запрашивается. Если исправленный ответ корректен, агент видит только +итоговый вызов, без уведомления. Дальше исход задаёт `TOOLCALL_INVALID_MODE` +(ниже). При `LEGACY=1` repair по-прежнему только для DeepSeek (main-era промпт). +Проверено офлайн. + +`TOOLCALL_INVALID_MODE` (по умолчанию `error`; только при `LEGACY=0`): что делать с +невалидным вызовом после проверки или после неудачной одной коррекции — для +**любого** провайдера, включая DeepSeek. При `error` агент получает +`invalid_tool_response` (fail-closed). При `text` — обычный HTTP-успех с живым +чатом провайдера, но явный неуспех шага для агента: + +- в `content` — сырой ответ и строка `Bridge notice: BRIDGE_TOOL_NOT_EXECUTED: …` + (имя инструмента максимум; без схем и значений); +- сверху ответа — объект `bridge_notice` (`executed: false`, `code`, `kind`, `tool`, + `as_tool_error` как заготовка ошибки tool); +- в `message.refusal` — тот же текст notice (для клиентов, которые читают refusal); +- следующее сообщение пользователя уходит в закладку провайдера как + `Tool result (имя): ` плюс текст follow-up — как будто tool вернул ошибку. + +Исполняемые `tool_calls` при отказе не выдумываются (агент мог бы их запустить). +Даже при `text` ошибкой остаются: `tool_choice` required/конкретный инструмент, +ответ без ID беседы и оборванный ответ. Совет browser под видом вызова (нет +call-level `name` или неизвестное имя с прозой) до этого пути сбрасывается в fence +`text`. Проверено офлайн. + +Вызов, который повторяет с теми же аргументами вызов, результат которого есть в +запросе (модель на результат отвечает тем же вызовом), второй раз не выполняется: +при `TOOLCALL_INVALID_MODE=text` агент получает такое же сообщение (сырой ответ и +строка о повторе), при `error` — ошибка. Другой вызов или тот же вызов после нового +сообщения пользователя выполняется как обычно. Проверено офлайн. + +Результат инструмента, который возвращает агент (например выбранный вами вариант), +уходит провайдеру просто как `Tool result (имя): `. В нём нет +напоминания про инструменты (`TOOL_INSTRUCTIONS_BROWSER` по-прежнему действует для +обычных сообщений пользователя), повтора вопроса или вызова и внутреннего ID вызова; +результаты нескольких вызовов одного инструмента сохраняют сопоставление +идентификаторов. На исход невалидного вызова влияет `TOOLCALL_INVALID_MODE` (выше). +Проверено офлайн. + +Для DeepSeek отключение `TOOLCALL_FORMAT_REPAIR` запрещает дополнительную генерацию для исправления формата, но не отключает проверку разметки инструментов. Некорректный блок вызовов останавливает шаг; он не выдаётся за обычный успешный текст. После нормального завершения ответа DeepSeek допускается пояснение перед одним @@ -249,9 +338,10 @@ HTTP-адрес loopback без учётных данных, пути, query и перед его строкой открытия есть пустая строка. Цитируемые, вложенные и неоднозначные блоки кода не выполняются; маркеры `>` никогда не удаляются из кода. Обычные блоки `json` и цитаты не становятся вызовами. Явный `tool_calls` — протокол -действия, поэтому не используйте его для примеров в пояснении. Удалённые схемы не -загружаются; локальные `$ref` поддерживаются. При обновлении установите зависимости -из `requirements.txt` (добавлен `jsonschema`) и перезапустите мост. +действия, поэтому не используйте его для примеров в пояснении. Браузерные провайдеры +подчиняются правилам выше. Удалённые схемы не загружаются; локальные `$ref` +поддерживаются. При обновлении установите зависимости из `requirements.txt` +(добавлен `jsonschema`) и перезапустите мост. Также при нормальном завершении и закрытом блоке допускается перестановка двух последних скобок `]}` → `}]`, если строковый анализ подтверждает: аргументы уже @@ -425,7 +515,10 @@ DeepSeek не было; файл бюджетов не изменился, пр за час/сутки**, общий лимит 20 принятых ответов в одном диалоге (`CONVERSATION_MAX_REPLIES`). Интервал — 30 секунд. Бюджеты `DEEPSEEK_MAX_*` и `CONVERSATION_MAX_REPLIES` задаются в окружении процесса; увеличение локального -потолка не увеличивает квоту сайта. [Настройка и разбор ошибок](docs/HARNESS.ru.md#локальные-лимиты-и-ошибки). +потолка не увеличивает квоту сайта. `PROVIDER_ACCESS_PAUSE=0` отключает именно +durable pause и pending-блокировки провайдера (включая сохранённые ранее), но не +лимиты, квоты провайдера и защиту истории диалога; риск повтора после неопределённой +ошибки остаётся. [Настройка и разбор ошибок](docs/HARNESS.ru.md#локальные-лимиты-и-ошибки). ## Подключение OpenCode @@ -433,6 +526,8 @@ DeepSeek не было; файл бюджетов не изменился, пр [пример `opencode.json`](examples/opencode.json) в папку проекта. Если конфиг уже есть, объедините настройки, сохранив свои `provider`, `model` и `small_model`. Задавайте `small_model` явно: заголовки тоже могут расходовать сообщения. +Не направляйте title/`small_model` на браузерную модель — иначе откроется +второй чат во вкладке (Harness отключает `session-title-llm` в профиле). ```bash cd /path/to/your-project @@ -476,7 +571,9 @@ opencode --agent plan --model local-qwen/qwen3.8-omni-flash **Инструменты выполняет Harness или OpenCode на вашем компьютере.** Промпты и прочитанные инструментами данные уходят выбранному провайдеру. Мост проверяет ответы и `tool_calls`, сохраняет диалог и отправляет новые результаты инструментов -в существующий чат. Неизвестная история останавливается, а не создаёт новый чат. +в существующий чат. Сопоставление продолжения игнорирует клиентский `system` и +обёртки OpenCode `` (они меняются между ходами). Неизвестная +или уже израсходованная история останавливается, а не создаёт новый чат. Паузы при отказах сохраняются после перезапуска. Настройка клиента, обычный вход и сброс локального бюджета сами по себе их не снимают. Перенос через Safari @@ -498,6 +595,11 @@ API по умолчанию слушает `127.0.0.1` (только локал или 15 секунд без байтов; такая остановка ставит паузу провайдера и не повторяется. Первый запрос (ещё без ответа ассистента) не имеет ссылки истории, поэтому его повторная отправка может открыть второй чат; продолжения одноразовые. +Чтобы при запуске удалять уже использованные ссылки кэша, задайте +`CONVERSATION_CLEAR_USED_ON_STARTUP=1` в окружении процесса моста; по умолчанию +`0` ничего не удаляет. Это позволяет создать новый чат с повторённым первым +сообщением, но при неодинаковых upstream-чатах с одинаковой историей продолжение +может привязаться к неверному чату и завершиться ошибкой. Ссылки `used=0` не удаляются. ## Демонстрации OpenCode @@ -510,6 +612,19 @@ API по умолчанию слушает `127.0.0.1` (только локал [Кадры GLM/Kimi и методика записи](docs/media/README.md). +Повторный начальный запрос с **точно такой же историей** имеет тот же ключ кэша. +Пока связь сохранена, мост не открывает для этой истории новый чат: он может +переиспользовать ещё не использованную связь или отклонить повтор уже использованной. +По умолчанию использованные/закрытые связи сохраняются, чтобы не привязать повтор +к другому чату. +При `CONVERSATION_CLEAR_USED_ON_STARTUP=1` при старте удаляются только +использованные связи (`used=1`), поэтому такой повтор может создать новый upstream-чат, +а последующее продолжение может ошибочно привязаться к другому чату. +Полная ручная очистка: `python -m server.conversation_admin -clear` удаляет все +записи `links`, связанные с ними пути логов (`trace_links`) и записи миграций +(`rollovers`) из `session/deepseek-conversations.sqlite3`. Файлы логов и чаты у +провайдера не удаляются. Команда разрушительная. + ## Документация и разработка - [Harness: подключение, сессии, бюджеты, офлайн-проверка](docs/HARNESS.ru.md) @@ -529,7 +644,7 @@ Claude требует terminal marker сетевого потока; обрез ответ не выдаётся как завершённый. При неопределённом исходе отправки остаются pending и защитная пауза, в том числе после перезапуска. Автоповтора нет. Исправления проверены офлайн; живые аккаунты не использовались. Для применения -браузерных изменений заново экспортируйте и обновите userscripts (controller 0.3.34). +браузерных изменений заново экспортируйте и обновите userscripts (controller 0.3.35). Mistral принимает только привязанный к запросу поток с конечным trailer. Режим Harness `--without-browser` исключает браузерные модели и их алиасы. diff --git a/browser/README.md b/browser/README.md index 6d8591f..c3b0b6c 100644 --- a/browser/README.md +++ b/browser/README.md @@ -29,14 +29,14 @@ content context and keeps its local pairing key out of page JavaScript. 2. Install Userscripts and add the two exported `.user.js` files to its scripts directory. Grant access only to `chat.z.ai`, `grok.com`, `chat.mistral.ai`, `www.kimi.com` (or `www.kimi.ai`), `chat.qwen.ai`, `chatgpt.com` (and - `chat.openai.com` if still used), `claude.ai`, `gemini.google.com`, and - `www.google.com`; the bridge does not need access to Google Account settings - pages beyond the chat UI. + `chat.openai.com` if still used), `claude.ai`, `chat.chatgbot.ai`, + `gemini.google.com`, and `www.google.com`; the bridge does not need access to + Google Account settings pages beyond the chat UI. After adding or replacing files, open the Userscripts toolbar popup and wait until both script names appear. The extension must rescan externally edited files before the next page load, as described in its linked README. After upgrading the bridge, export and replace the scripts again. The current - controller is 0.3.34 and observer is 0.3.17 (Claude thinking streamDone). + controller is 0.3.35 and observer is 0.3.18 (ChatGBot Claude `/en/chat`). On a failed job the badge shows `ERROR: …` with the reason (hover for full text). Older loaded scripts do not include all lease checks. 3. Keep the API bound to `127.0.0.1:8000`. Set `BROWSER_BRIDGE_ENABLED=1` and enable @@ -138,14 +138,17 @@ account quotas, captchas and Sentinel challenges still apply per session. ## Claude Claude is browser-only (`CLAUDE_ENABLED=1`, no transport flag). Pair a signed-in -tab on `https://claude.ai/`, use model id `claude-web`, and select the model in -the site UI. The controller fills the ProseMirror composer -(`[data-testid="chat-input"]`) and clicks Send. The observer captures only +tab on [ChatGBot](https://chat.chatgbot.ai/en/chat) (preferred) or +`https://claude.ai/`, use model id `claude-web`, and select the model in the +site UI. The controller fills the ProseMirror composer +(`[data-testid="chat-input"]`) and clicks Send. On ChatGBot the empty chat is +`/en/chat` and threads are `/en/chat/`; bridge tokens stay `/chat/`. +The observer captures only `/api/organizations/.../chat_conversations//completion` SSE for the active job (line-oriented `content_block_delta` / `message_stop`; thinking frames with `"stop_reason": null` are not treated as done). An incomplete wire body is -rejected; visible DOM text cannot substitute for a terminal network response. Conversation tokens use -`/chat/`. Do not point OpenCode `small_model` / session-title at `claude-web` +rejected; visible DOM text cannot substitute for a terminal network response. +Do not point OpenCode `small_model` / session-title at `claude-web` while a long turn is running — use another provider or disable title LLM (Harness already disables `session-title-llm`). Captchas and plan limits must be resolved in the normal tab. Live browser-adapter checks for this provider have diff --git a/browser/opencode-controller.user.js b/browser/opencode-controller.user.js index 4d9d11e..26d4771 100644 --- a/browser/opencode-controller.user.js +++ b/browser/opencode-controller.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name OpenCode signed-in tab controller // @namespace opencode-local-bridge -// @version 0.3.34 +// @version 0.3.35 // @description Opt-in local jobs in your existing signed-in browser tab. // @match https://chat.z.ai/* // @match https://grok.com/* @@ -12,6 +12,7 @@ // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://claude.ai/* +// @match https://chat.chatgbot.ai/* // @match https://gemini.google.com/* // @match https://www.google.com/* // @inject-into content @@ -65,6 +66,11 @@ looseEmpty: true, rebindEditor: true, ignoreMarkup: true, fillDelayMs: 40, fillFallback: "paragraph", clearForeignDraft: true, stalls: true, }; + // ChatGBot Claude UI: locale-prefixed routes; bridge tokens stay /chat/. + const claudeChatgbotSite = { + ...claudeSite, + home: "/en/chat", + }; const sites = { "chat.z.ai": { provider: "glm", home: "/", input: "#chat-input", path: /^\/c\/[\w-]+$/ }, "grok.com": { @@ -87,6 +93,7 @@ "chatgpt.com": chatgptSite, "chat.openai.com": chatgptSite, "claude.ai": claudeSite, + "chat.chatgbot.ai": claudeChatgbotSite, "gemini.google.com": { provider: "gemini", home: "/app", input: geminiInput, stalls: true, path: /^\/(?:u\/\d+\/)?app\/[\w-]+$/, @@ -113,6 +120,20 @@ const googleAiModePage = () => location.hostname === "www.google.com" && location.pathname === "/search" && new URLSearchParams(location.search).get("udm") === "50"; + // ChatGBot: browser shows /en/chat[/id]; bridge tokens stay /chat/. + const claudeChatgbotPage = () => location.hostname === "chat.chatgbot.ai"; + const claudeCanonicalPath = value => { + const path = String(value || ""); + if (path === "/en/chat" || /^\/[a-z]{2}\/chat$/.test(path)) return site.home; + const match = path.match(/^\/(?:[a-z]{2}\/)?chat\/([\w-]+)$/); + return match ? "/chat/" + match[1] : path; + }; + const claudeBrowserPath = value => { + const path = String(value || ""); + if (path === site.home || path === "/en/chat") return "/en/chat"; + const match = path.match(/^\/chat\/([\w-]+)$/); + return match ? "/en/chat/" + match[1] : path; + }; const sameRoute = (pathname, target) => { if (location.hostname === "www.google.com") { let targetPath = target; @@ -127,6 +148,9 @@ } return geminiAppRoute(pathname) === geminiAppRoute(targetPath); } + if (claudeChatgbotPage() && site.provider === "claude") { + return claudeCanonicalPath(pathname) === claudeCanonicalPath(target); + } if (pathname === target) return true; if (site.provider !== "gemini") return false; // gemini.google.com cannot host the AI Mode placeholder token. @@ -396,6 +420,7 @@ kimiSite.send = kimiSend; chatgptSite.send = chatgptSend; claudeSite.send = claudeSend; + claudeChatgbotSite.send = claudeSend; async function submitComposer(input, prompt, form) { const send = site.send; if (!send) { @@ -612,7 +637,9 @@ } // Give the frontend a bounded chance to render its final answer and route. for (let attempt = 0; attempt < 50 && pending === job; attempt++) { - if (site.path.test(location.pathname)) break; + const route = claudeChatgbotPage() + ? claudeCanonicalPath(location.pathname) : location.pathname; + if (site.path.test(route)) break; await new Promise(resolve => setTimeout(resolve, 100)); } if (pending !== job) return; @@ -623,6 +650,9 @@ reported = match ? "/app/" + match[1] : reported; } + if (claudeChatgbotPage() && site.provider === "claude") { + reported = claudeCanonicalPath(reported); + } await finish({ status: value.status, body: value.body, path: reported, ...(value.request_turn ? { request_turn: value.request_turn } : {}), }).catch(() => label("OpenCode: ответ не принят")); @@ -651,6 +681,9 @@ // Bounce that token to the AI Mode search URL — never gemini.google.com. let navTarget = target; if (geminiAiModeToken(target)) navTarget = "/search?udm=50"; + if (claudeChatgbotPage() && site.provider === "claude") { + navTarget = claudeBrowserPath(target); + } const origin = location.hostname === "www.google.com" && /^\/(?:u\/\d+\/)?app\//.test(navTarget) && navTarget !== "/app/aimode" diff --git a/browser/opencode-observer.user.js b/browser/opencode-observer.user.js index 18cd51d..19898fb 100644 --- a/browser/opencode-observer.user.js +++ b/browser/opencode-observer.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name OpenCode browser response observer // @namespace opencode-local-bridge -// @version 0.3.17 +// @version 0.3.18 // @description Observe only the completion caused by an active local bridge job. // @match https://chat.z.ai/* // @match https://grok.com/* @@ -12,6 +12,7 @@ // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://claude.ai/* +// @match https://chat.chatgbot.ai/* // @match https://gemini.google.com/* // @match https://www.google.com/* // @inject-into page @@ -39,6 +40,7 @@ "chat.openai.com": { provider: "chatgpt", stalls: true, fullTail: true }, // Claude wire paths drift; capture same-origin /api/* and fall back to DOM scrape. "claude.ai": { provider: "claude", stalls: true, fullTail: true }, + "chat.chatgbot.ai": { provider: "claude", stalls: true, fullTail: true }, "gemini.google.com": { provider: "gemini", stalls: true, fullTail: true }, "www.google.com": { provider: "gemini", stalls: true, fullTail: true }, }; @@ -567,7 +569,8 @@ } } catch (_) { /* opaque / stream bodies */ } if (job.path !== null) return { path: job.path }; - const match = location.pathname.match(/^\/chat\/([\w-]+)$/); + // claude.ai: /chat/. ChatGBot: /en/chat/ (locale prefix). + const match = location.pathname.match(/^\/(?:[a-z]{2}\/)?chat\/([\w-]+)$/); return { path: match ? "/chat/" + match[1] : null }; }; diff --git a/chat_protocol.py b/chat_protocol.py index 5bdaef5..f88faed 100644 --- a/chat_protocol.py +++ b/chat_protocol.py @@ -1,6 +1,7 @@ """Common transport types and SSE framing for the web-chat providers.""" from dataclasses import dataclass +from typing import Optional @dataclass @@ -8,6 +9,8 @@ class Reply: text: str conversation_id: str finish_reason: str = "stop" + # Optional structured notice for TOOLCALL_INVALID_MODE=text (agent-visible). + bridge_notice: Optional[dict] = None def __str__(self) -> str: return self.text diff --git a/docs/ACCOUNT_SAFETY.md b/docs/ACCOUNT_SAFETY.md index c3a54bb..736c990 100644 --- a/docs/ACCOUNT_SAFETY.md +++ b/docs/ACCOUNT_SAFETY.md @@ -77,6 +77,12 @@ reason (never a raw upstream body), states that this bridge enabled a durable Later requests hit that local pause before any browser job or CLI process is created and return HTTP `400` (same allowlisted `code`, `retryable: false`) with an explicit “manual providers.access pause” message and the same resume command. + +`PROVIDER_ACCESS_PAUSE` (default `1`) controls that durable latch. With `0` the +bridge neither writes nor enforces those pauses (this request still fails). That +opt-out is for local testing only, at your own risk: it can keep sending traffic +to a restricted account and worsen blocks. Pending one-use attempts and local +budgets are unchanged. Using `400` here avoids clients that rewrite every `403` into a generic “API key” warning. If streaming headers are already sent, the first failure is a terminal SSE error with the same wording. Invalid local tool responses use HTTP `400`, @@ -138,18 +144,21 @@ local refusal behavior, not that live website automation is permitted or safe. ## DeepSeek load containment and conversation reuse The October 10 account restriction showed that bounded task success did not -establish account safety. Keep `TOOLCALL_FORMAT_REPAIR=0` and -`TOOLCALL_MAX_CONTINUATIONS=0`: format errors stop locally, with no additional -model generation. The following changes are qualified using offline transports -and an isolated Harness CLI, not by further requests to the restricted account. +establish account safety. Keep `TOOLCALL_FORMAT_REPAIR=0`, +`TOOLCALL_MAX_CONTINUATIONS=0`, and `TOOLCALL_INVALID_MODE=error`: format errors +stop locally, with no additional model generation and no soft notice path. The +following changes are qualified using offline transports and an isolated Harness +CLI, not by further requests to the restricted account. Stateless OpenAI clients continue a saved provider conversation when their -history exactly matches a delivered reply. This applies to DeepSeek and to +non-system history matches a delivered reply. This applies to DeepSeek and to opt-in web providers (including Qwen browser tab bridge): only new tool results/user messages and current tool instructions are sent, so the browser tab resumes `/c/` instead of opening another chat with the full system -prompt. A private SQLite file in `session/` stores history hashes and provider -conversation IDs, never message bodies. +prompt. Client `system` messages and ephemeral OpenCode `` +wrappers are omitted from the history hash (they change between agent turns and +are not forwarded on continuation). A private SQLite file in `session/` stores +history hashes and provider conversation IDs, never message bodies. Links are reserved atomically before dispatch. A reservation is released only when the current request fails with a proven absence of HTTP dispatch. Once any HTTP request was dispatched, or after a process crash, the link stays consumed. Unknown, edited, compacted, diff --git a/docs/HARNESS.en.md b/docs/HARNESS.en.md index bc2ac47..a329886 100644 --- a/docs/HARNESS.en.md +++ b/docs/HARNESS.en.md @@ -155,7 +155,8 @@ after a manual website check; see [account safety](ACCOUNT_SAFETY.md). All providers have account limits, including Chinese models. Tool continuations can consume another message. -Defaults remain `TOOLCALL_FORMAT_REPAIR=0` and `TOOLCALL_MAX_CONTINUATIONS=0`. The bridge resumes saved conversations and sends only new results; unknown history stops without creating a new chat. Rolling budgets and a configurable conversation cap (20 accepted replies by default) still apply. Historical checks below do not establish readiness for unlimited tasks. +Defaults remain `TOOLCALL_FORMAT_REPAIR=0`, `TOOLCALL_MAX_CONTINUATIONS=0` and +`TOOLCALL_INVALID_MODE=error`. The bridge resumes saved conversations and sends only new results; unknown history stops without creating a new chat. Rolling budgets and a configurable conversation cap (20 accepted replies by default) still apply. Historical checks below do not establish readiness for unlimited tasks. For unattended DeepSeek goals, optionally set `TOOLCALL_FORMAT_REPAIR=1` in the bridge process environment and restart the bridge. After a normally @@ -248,7 +249,7 @@ large live Expert task has not been qualified after these changes. `CONVERSATION_MAX_REPLIES` separately controls the conversation cap (default 20, range 1–1000). It counts accepted replies, not all upstream generations: format correction also consumes the rolling budget. After raising it and restarting, exact saved history can continue in the same conversation. Counters and one-use protection are preserved; unknown or edited histories remain rejected. -An empty or whitespace-only tool-mode reply is rejected instead of reported as success. With `TOOLCALL_FORMAT_REPAIR=1`, normal completion permits one correction in the same conversation; a second empty reply returns an explicit error. Output limits, refusals and `tool_choice=none` never trigger correction. +An empty or whitespace-only tool-mode reply is rejected instead of reported as success. With `TOOLCALL_FORMAT_REPAIR=1`, normal completion permits one correction in the same conversation; a second empty reply follows `TOOLCALL_INVALID_MODE` (`error` by default — explicit failure; `text` — Bridge notice). Output limits, refusals and `tool_choice=none` never trigger correction. ### Local budget commands diff --git a/docs/HARNESS.ru.md b/docs/HARNESS.ru.md index bd243d2..6db2682 100644 --- a/docs/HARNESS.ru.md +++ b/docs/HARNESS.ru.md @@ -165,7 +165,8 @@ Gemini работает через официальный CLI; в нашем т Квоты действуют у всех провайдеров, включая китайские модели; каждый шаг агента и ответ после инструмента может расходовать отдельное сообщение. -По умолчанию `TOOLCALL_FORMAT_REPAIR=0` и `TOOLCALL_MAX_CONTINUATIONS=0`. Мост сохраняет беседу между шагами и отправляет только новые результаты; неизвестная история останавливается без создания нового чата. Сохраняются лимиты нагрузки и настраиваемый предел принятых ответов на беседу (по умолчанию 20). Исторические проверки ниже не означают готовности к неограниченным задачам. +По умолчанию `TOOLCALL_FORMAT_REPAIR=0`, `TOOLCALL_MAX_CONTINUATIONS=0` и +`TOOLCALL_INVALID_MODE=error`. Мост сохраняет беседу между шагами и отправляет только новые результаты; неизвестная история останавливается без создания нового чата. Сохраняются лимиты нагрузки и настраиваемый предел принятых ответов на беседу (по умолчанию 20). Исторические проверки ниже не означают готовности к неограниченным задачам. Для автономных целей DeepSeek доступен опциональный `TOOLCALL_FORMAT_REPAIR=1` в окружении процесса моста. Перезапустите мост после изменения настройки. @@ -260,7 +261,7 @@ Harness CLI на локальном подменённом провайдере Предел беседы отдельно задаёт `CONVERSATION_MAX_REPLIES` (по умолчанию 20, допустимо 1–1000). Он считает принятые ответы, а не все upstream-генерации: коррекция формата дополнительно расходует скользящий бюджет. После повышения и перезапуска точная сохранённая история может продолжиться в той же беседе. Счётчик и защита от повторного исполнения сохраняются; неизвестная или изменённая история по-прежнему отклоняется. -Пустой или состоящий из пробелов ответ в режиме инструментов больше не выдаётся как успех. При `TOOLCALL_FORMAT_REPAIR=1` нормально завершённый ответ допускает одну коррекцию в той же беседе; второй пустой ответ возвращает явную ошибку. При обрыве по длине, отказе или `tool_choice=none` коррекция не запускается. +Пустой или состоящий из пробелов ответ в режиме инструментов больше не выдаётся как успех. При `TOOLCALL_FORMAT_REPAIR=1` нормально завершённый ответ допускает одну коррекцию в той же беседе; второй пустой ответ следует `TOOLCALL_INVALID_MODE` (`error` по умолчанию — явная ошибка; `text` — Bridge notice). При обрыве по длине, отказе или `tool_choice=none` коррекция не запускается. ### Счётчики и ручной сброс diff --git a/docs/SETUP.en.md b/docs/SETUP.en.md index 4638364..a9803ed 100644 --- a/docs/SETUP.en.md +++ b/docs/SETUP.en.md @@ -407,13 +407,46 @@ Check: open the model picker in opencode — you should see ### 5. Why the model doesn't make edits (emulated tool calling) -The DeepSeek web chat has **no function-calling channel**, so the bridge emulates -it in text: it injects an instruction into the prompt ("reply with exactly one -fenced `tool_calls` block containing a JSON array") and then parses the reply back -([server/openai_format.py](../server/openai_format.py)). If the model writes prose -instead of a call, opencode simply prints the text and **executes nothing**. - -What was done for reliability: +Provider web chats have **no native function-calling channel** like the OpenAI +API, so the bridge emulates it in text: a prompt instruction plus parsing a +fenced `tool_calls` block ([server/openai_format.py](../server/openai_format.py), +[server/tool_protocol.py](../server/tool_protocol.py)). Without a valid call the +agent gets ordinary text and **executes nothing**. + +Temporary `TOOLCALL_LEGACY` (default `1`): + +| | `TOOLCALL_LEGACY=1` (default, main-era) | `TOOLCALL_LEGACY=0` (tool-rework) | +|--|----------------------------------------|----------------------------------| +| Code | `server/api.py` + `server/tool_protocol.py` | `server/api_new.py` + `server/tool_protocol_new.py` | +| Providers | One contract for everyone | Same single contract for everyone (incl. DeepSeek) | +| Blocks | One protocol block (main-era / DeepSeek tolerances) | Several top-level `` ```tool_calls `` blocks anywhere | +| Advice mistagged as a call | Package error | Demote to a `text` fence; no repair | +| Invalid call | Always `invalid_tool_response` | `TOOLCALL_INVALID_MODE` (`error` / `text`) | +| Format repair | DeepSeek only, main-era prompt | Pointed repair for every provider | +| Continuation on `length` | Unclosed fence at reply start | Unclosed top-level fence anywhere | + +The flag will be removed; `TOOLCALL_INVALID_MODE` applies only when `LEGACY=0`. +Demote, `bridge_notice`, and repeat details are in [README.en.md](../README.en.md); +longer notes (Russian): +[toolcall-format-repair-legacy.md](toolcall-format-repair-legacy.md) (`LEGACY=1`) and +[toolcall-format-repair.md](toolcall-format-repair.md) (`LEGACY=0`). + +#### `TOOLCALL_LEGACY=0` (single contract) + +For **every** provider, including DeepSeek: only a top-level fenced `tool_calls` +block is a call; mistagged advice is demoted; every remaining block must validate; +with `FORMAT_REPAIR=1` one pointed correction (catalog / argument shape); otherwise +`INVALID_MODE` decides the outcome. There is no DeepSeek freeze parser and no +separate “other providers” fork under this path. + +#### `TOOLCALL_LEGACY=1` (main-era) + +Main-era path in `api` / `tool_protocol`: one whole-reply block, historical DeepSeek tolerances (missing +closing fence, extra `}`, DSML closers, flat fields → `arguments`, restoring one +`]`), DeepSeek-only repair, always `invalid_tool_response`. If the model writes +prose instead of a call, opencode prints the text and **executes nothing**. + +What the main-era parser keeps: - only one complete `tool_calls` block occupying the entire reply becomes calls; JSON in prose, XML/DSML, and pseudo-calls remain ordinary text; @@ -444,17 +477,21 @@ What was done for reliability: Malformed JSON errors report a fixed category and a line/column within the JSON payload, without exposing arguments. No calls from that reply are returned and -by default the malformed response is not automatically repaired or replayed. The prompt -uses a valid JSON example and explicit escaping rules; this reduces ambiguity -but cannot guarantee that every model response follows the protocol. - -For unattended DeepSeek tool use, `TOOLCALL_FORMAT_REPAIR=1` permits one additional -paced request in the same conversation after a normally completed invalid reply -(including commentary mixed with a tool block or native DSML call markup). The model must generate a fresh, -fully validated response; the bridge never guesses or edits argument values. -The first batch is not executed. A second invalid reply is terminal. Account or -quota refusals, cancellation, missing conversation IDs and output-limit responses -do not trigger correction. This is off by default and does not guarantee autonomy. +by default the malformed response is not automatically repaired or replayed. + +With `LEGACY=0` and `TOOLCALL_FORMAT_REPAIR=1`, one pointed correction in the same +conversation is allowed after a normally completed invalid reply for any provider +(unknown → catalog; schema → argument shape). Advice with no `name` or +unknown+prose is already demoted to `text` and is not corrected. A second failure +follows `TOOLCALL_INVALID_MODE` (below). With `LEGACY=1`, repair remains +DeepSeek-only (main-era prompt). Off by default; autonomy is not guaranteed. + +`TOOLCALL_INVALID_MODE` (`error` by default, or `text`; `LEGACY=0` only): outcome +for an invalid call after validation or one failed correction, for **any** +provider. `error` raises `invalid_tool_response`; `text` keeps the provider chat +alive and marks an explicit failed step for the agent (`BRIDGE_TOOL_NOT_EXECUTED` +in content, `bridge_notice`, `refusal`; next user turn reaches the tab as +`Tool result (name): …`). Details in [README.en.md](../README.en.md). For private reproduction of invalid tool replies, set `TOOLCALL_DIAGNOSTICS=1`. The last failed reply and advertised tool schemas are saved atomically to @@ -642,10 +679,14 @@ variables take precedence. | `DEEPSEEK_PROFILE_DIR` | — | Reuse an existing Chrome profile with an active session | | `DEEPSEEK_ENABLED` | `0` | Explicit DeepSeek opt-in; account restriction risk | | `PROVIDER_MIN_INTERVAL` | `30` | Seconds between provider attempts/continuations; not a quota guarantee | +| `PROVIDER_ACCESS_PAUSE` | `1` | Durable pause + `access resume`; `0` testing only (account-block risk) | | `QWEN_ENABLED` | `0` | Enable Qwen Chat models on the same `/v1` endpoint | | `QWEN_MODELS` | `qwen3.8-omni-flash`, `qwen3.8-max`, `qwen3.7-plus` | JSON map of public id → website wire model (HTTP transport) | | `QWEN_PROFILE_DIR` | `session/qwen/profile` | Separate persistent Qwen Chat profile | -| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Explicit opt-in continuation budget for provider-confirmed output limits; normal stops never trigger continuation | +| `TOOLCALL_LEGACY` | `1` | Temporary: `1` — main-era for all; `0` — one tool-rework for all (will be removed) | +| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Continuation budget on output limit; with `LEGACY=0` — unclosed fence anywhere | +| `TOOLCALL_FORMAT_REPAIR` | `0` | `1` — one correction (`LEGACY=1`: DeepSeek only; `LEGACY=0`: DeepSeek / browser) | +| `TOOLCALL_INVALID_MODE` | `error` | Only when `LEGACY=0`: `error` / `text` (notice + `bridge_notice`) | | `TOOLCALL_DEBUG` | — | `1` — log the raw model reply when a call can't be parsed | Example: diff --git a/docs/SETUP.ru.md b/docs/SETUP.ru.md index 0a60cca..519b2ec 100644 --- a/docs/SETUP.ru.md +++ b/docs/SETUP.ru.md @@ -455,13 +455,47 @@ opencode читает конфиг один раз при старте. **Пол ### 5. Почему модель не вносит правки (эмуляция tool calling) -У веб-чата DeepSeek **нет канала function calling**, поэтому мост эмулирует его -текстом: подмешивает в промпт инструкцию «ответь ровно одним блоком кода -`tool_calls` с JSON-массивом» и затем парсит ответ обратно -([server/openai_format.py](../server/openai_format.py)). Если модель пишет прозу -вместо вызова, opencode просто печатает текст и **ничего не выполняет**. - -Что сделано для надёжности: +У веб-чатов провайдеров **нет нативного function calling** в том виде, как у +OpenAI API, поэтому мост эмулирует его текстом: инструкция в промпте + разбор +fenced-блока `tool_calls` ([server/openai_format.py](../server/openai_format.py), +[server/tool_protocol.py](../server/tool_protocol.py)). Без валидного вызова +агент получает обычный текст и **ничего не выполняет**. + +Временный `TOOLCALL_LEGACY` (по умолчанию `1`): + +| | `TOOLCALL_LEGACY=1` (default, как main) | `TOOLCALL_LEGACY=0` (tool-rework) | +|--|----------------------------------------|----------------------------------| +| Код | `server/api.py` + `server/tool_protocol.py` | `server/api_new.py` + `server/tool_protocol_new.py` | +| Провайдеры | Один контракт для всех | Тот же единый контракт для всех (в т.ч. DeepSeek) | +| Блоки | Один протокол-блок (допуски main-era / DeepSeek) | Несколько верхнеуровневых `` ```tool_calls `` где угодно | +| Совет под видом вызова | Ошибка пакета | Demote → fence `text`; не repair | +| Невалидный вызов | Всегда `invalid_tool_response` | `TOOLCALL_INVALID_MODE` (`error` / `text`) | +| Format repair | Только DeepSeek, main-era промпт | Pointed repair для любого провайдера | +| Continuation при `length` | Незакрытый fence с начала ответа | Незакрытый top-level fence где угодно | + +Флаг будет удалён; `TOOLCALL_INVALID_MODE` действует только при `LEGACY=0`. +Подробности demote, `bridge_notice` и repeat — в [README.md](../README.md); +развёрнутые конспекты: +[toolcall-format-repair-legacy.md](toolcall-format-repair-legacy.md) (`LEGACY=1`) и +[toolcall-format-repair.md](toolcall-format-repair.md) (`LEGACY=0`). + +#### `TOOLCALL_LEGACY=0` (единый контракт) + +Для **любого** провайдера, включая DeepSeek: вызовом является только верхнеуровневый +fenced-блок `tool_calls`; demote «совета»; каждый оставшийся блок обязан быть +валидным; при `FORMAT_REPAIR=1` — одна pointed-коррекция (каталог / форма +аргументов); иначе исход задаёт `INVALID_MODE`. Отдельного freeze-парсера DeepSeek +и ветки «остальные» нет. + +#### `TOOLCALL_LEGACY=1` (как на main) + +Путь main-era в `api` / `tool_protocol`: один блок на весь ответ, допуски historically DeepSeek +(отсутствие закрывающей ограды, лишняя `}`, DSML-closers, flat arguments → +`arguments`, восстановление одного `]`), repair только для DeepSeek, всегда +`invalid_tool_response`. Если модель пишет прозу вместо вызова, opencode печатает +текст и **ничего не выполняет**. + +Что сохранено в main-era парсере: - только один полный блок `tool_calls`, занимающий весь ответ, превращается в вызовы; JSON в прозе, XML/DSML и псевдовызовы остаются обычным текстом; @@ -483,28 +517,29 @@ opencode читает конфиг один раз при старте. **Пол аргументы или нарушение `tool_choice` дают `502 invalid_tool_response`; - автопродолжения по умолчанию выключены. Явное включение действует только при подтверждённом провайдером лимите вывода; частичные вызовы не выдаются; +- после штатного завершения можно восстановить ровно одну отсутствующую внешнюю + скобку массива `]`, если все объекты вызовов и вложенные аргументы уже закрыты, + а полученный JSON целиком валиден; - инструкция усилена (запрет прозы + пример + напоминание в конце); - плагин дисциплины добавляет позднюю системную инструкцию. Ошибка JSON содержит фиксированную категорию и строку/столбец внутри JSON, без содержимого аргументов. Вызовы из такого ответа не выдаются; мост не чинит -команды догадками и по умолчанию не повторяет запрос автоматически. В инструкции используется -валидный образец JSON и явные правила экранирования. Это устраняет неоднозначность -инструкции, но не гарантирует соблюдение протокола моделью. - -После штатного завершения можно восстановить ровно одну отсутствующую внешнюю -скобку массива `]`, если все объекты вызовов и вложенные аргументы уже закрыты, -а полученный JSON целиком валиден. Запятая в конце, незавершённый элемент, -строка или вложенная скобка не исправляются. Дополнительной генерации нет. - -Для автономных задач DeepSeek можно включить `TOOLCALL_FORMAT_REPAIR=1`: после -штатно завершённого невалидного ответа разрешён один дополнительный запрос в той -же беседе с обычной паузой между запросами. Это касается и пояснений вокруг блока -инструментов, а также неподдерживаемых вызовов в DSML. Модель формирует новый ответ, который проходит полную проверку; -мост не угадывает аргументы, а исходный набор вызовов не исполняется. -Вторая ошибка завершает попытку. Отказ аккаунта или квоты, отмена, отсутствие ID -беседы и обрыв по длине не запускают исправление. По умолчанию режим выключен; -его включение не гарантирует автономность любой задачи. +команды догадками и по умолчанию не повторяет запрос автоматически. + +При `LEGACY=0` и `TOOLCALL_FORMAT_REPAIR=1`: после штатно завершённого невалидного +ответа — один pointed-запрос в той же беседе для любого провайдера (unknown → +каталог; schema → форма аргументов). Совет без `name` или unknown+проза уже +сброшен в `text` и коррекцию не получает. Вторая ошибка → `TOOLCALL_INVALID_MODE`. +При `LEGACY=1` repair остаётся только DeepSeek (main-era промпт). По умолчанию +выключено; автономность не гарантируется. + +`TOOLCALL_INVALID_MODE` (`error` по умолчанию, либо `text`; только `LEGACY=0`): +исход невалидного вызова после проверки или одной неудачной коррекции для +**любого** провайдера. `error` — `invalid_tool_response`; `text` — живой чат +провайдера плюс явный неуспех шага для агента (`BRIDGE_TOOL_NOT_EXECUTED` в +content, `bridge_notice`, `refusal`; следующий ход в закладку как +`Tool result (имя): …`). Подробности — [README.md](../README.md). Для локального воспроизведения ошибок задайте `TOOLCALL_DIAGNOSTICS=1`. Последний невалидный ответ и объявленные схемы инструментов атомарно сохраняются @@ -692,10 +727,14 @@ opencode run --model local-qwen/qwen3.8-max "Объясни этот проек | `DEEPSEEK_PROFILE_DIR` | — | Переиспользовать существующий профиль Chrome с активной сессией | | `DEEPSEEK_ENABLED` | `0` | Explicit DeepSeek opt-in; account restriction risk | | `PROVIDER_MIN_INTERVAL` | `30` | Seconds between provider attempts/continuations; not a quota guarantee | +| `PROVIDER_ACCESS_PAUSE` | `1` | Durable pause + `access resume`; `0` только для тестов (риск блокировки аккаунта) | | `QWEN_ENABLED` | `0` | Включить модели Qwen Chat на том же `/v1` | | `QWEN_MODELS` | `qwen3.8-omni-flash`, `qwen3.8-max`, `qwen3.7-plus` | JSON: публичный id → wire-модель сайта (HTTP-транспорт) | | `QWEN_PROFILE_DIR` | `session/qwen/profile` | Отдельный постоянный профиль Qwen Chat | -| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Явно включаемый лимит продолжений при подтверждённом провайдером обрыве по длине; штатное завершение не вызывает продолжений | +| `TOOLCALL_LEGACY` | `1` | Временно: `1` — main-era для всех; `0` — единый tool-rework для всех (будет удалён) | +| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Лимит продолжений при обрыве по длине; при `LEGACY=0` — незакрытый fence где угодно | +| `TOOLCALL_FORMAT_REPAIR` | `0` | `1` — одна коррекция (`LEGACY=1`: только DeepSeek; `LEGACY=0`: DeepSeek / browser) | +| `TOOLCALL_INVALID_MODE` | `error` | Только при `LEGACY=0`: `error` / `text` (notice + `bridge_notice`) | | `TOOLCALL_DEBUG` | — | `1` — писать в лог сырой ответ модели, если вызов не распознан | Пример: diff --git a/docs/toolcall-format-repair-legacy.md b/docs/toolcall-format-repair-legacy.md new file mode 100644 index 0000000..93a743e --- /dev/null +++ b/docs/toolcall-format-repair-legacy.md @@ -0,0 +1,66 @@ +# Tool-режим: `TOOLCALL_LEGACY=1` (main-era) + +Краткий конспект пути как на main: `server/api.py` + `server/tool_protocol.py` +для **всех** провайдеров. По умолчанию (`TOOLCALL_LEGACY=1`). + +Единый путь при `LEGACY=0`: [`toolcall-format-repair.md`](toolcall-format-repair.md) +(`api_new.py` + `tool_protocol_new.py`). + +Источники: `server/api.py`, `server/tool_protocol.py`, `server/openai_format.py`, `.env.example`. + +--- + +## Параметры (в режиме LEGACY=1) + +| Переменная | Default | Роль при `LEGACY=1` | +|------------|---------|---------------------| +| `TOOLCALL_LEGACY` | `1` | Этот путь; `0` → см. [`toolcall-format-repair.md`](toolcall-format-repair.md) | +| `TOOLCALL_FORMAT_REPAIR` | `0` | Одна коррекция формата; **только DeepSeek**, main-era промпт | +| `TOOLCALL_INVALID_MODE` | — | **Игнорируется** (всегда `invalid_tool_response`) | +| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Continuation при `length`, если ответ **начинается** открытым fence | + +--- + +## 1) Вход в tool-обработку + +- Есть `req.tools` → буфер → `tool_protocol.validate_reply` (не сырая трансляция потока). +- Один канон-блок `` ```tool_calls `` на весь ответ (preamble / `calls` / узкие + closer-fix при `stop` — допуски historically DeepSeek, для всех провайдеров). +- Несколько блоков / mid-message / demote — **нет** (это `LEGACY=0`). + +--- + +## 2) Format repair (`TOOLCALL_FORMAT_REPAIR=1`) + +Eligibility: штатный `stop`, есть `conversation_id`, `tool_choice != none`, +провайдер **DeepSeek**. Промпт: `_FORMAT_REPAIR_PROMPT` ± `repair_hint` +(JSON line/col). Один запрос; исходный пакет не исполняется. + +--- + +## 3) Исход невалидности + +Всегда `invalid_tool_response` агенту. `TOOLCALL_INVALID_MODE` не читается. + +Исполняемые `tool_calls` при отказе **не** выдумываются. + +--- + +## 4) Схема + +```text +req.tools? + нет → plain + да → tool_protocol.validate (+ optional FORMAT_REPAIR, только DeepSeek) + ├─ OK tool_calls / OK content + └─ ToolCallError → invalid_tool_response +``` + +--- + +## Что сохраняет main-era парсер + +- Контракт одного блока / markup→ошибка / пустой→ошибка +- `_FORMAT_REPAIR_PROMPT` и eligibility repair (DeepSeek) +- Локальные closer-fix без угадывания аргументов +- Continuation только через `TOOLCALL_MAX_CONTINUATIONS` (fence с начала ответа) diff --git a/docs/toolcall-format-repair.md b/docs/toolcall-format-repair.md new file mode 100644 index 0000000..265d661 --- /dev/null +++ b/docs/toolcall-format-repair.md @@ -0,0 +1,326 @@ +# Tool-режим моста (`TOOLCALL_LEGACY=0`) + +Единый tool-rework контракт для **всех** провайдеров, включая DeepSeek +(`TOOLCALL_INVALID_MODE`, demote, multi-block, pointed repair). + +Рядом: [toolcall-format-repair-legacy.md](toolcall-format-repair-legacy.md) +(`LEGACY=1` / main-era). + +Источники: `server/api.py`, `server/tool_protocol.py`, `server/openai_format.py`, `server/tool_notice.py`, `README.md`, `.env.example`. + +--- + +## Политика провайдеров (актуально) + +| `TOOLCALL_LEGACY` | Контракт | +|-------------------|----------| +| `1` (default) | main-era для **всех** (`api.py` + `tool_protocol.py`) | +| `0` | единый tool-rework для **всех** (`api_new.py` + `tool_protocol_new.py`) | + +Решение: при `LEGACY=0` нет отдельного парсера DeepSeek. + +--- + +## `TOOLCALL_INVALID_MODE` (`error` \| `text`, default `error`) + +После `ToolCallError` (и после неудачной одной коррекции), для **любого** провайдера +(DeepSeek и browser). Термины: **провайдер** = закладка с чатом ИИ; **мост** = наше +приложение; **агент** = приложение, куда пишет человек. + +### Схема вызовов (актуальная) + +```text +человек → агент → мост → провайдер (закладка) + ← ← + +Провайдер вернул ответ + → мост: validate (+ опц. TOOLCALL_FORMAT_REPAIR=1 → один correction в ту же беседу) + → OK: агенту tool_calls и/или content + → ToolCallError: + ├─ INVALID_MODE=error (default) + │ → агенту HTTP/SSE invalid_tool_response + │ → риск обрыва продолжения истории у агента + │ + └─ INVALID_MODE=text (нужны: tool_choice auto, stop, conversation_id) + → агенту HTTP 200, чат провайдера жив: + content: + <сырой ответ провайдера> + + Bridge notice: BRIDGE_TOOL_NOT_EXECUTED: … NOT executed: … + message.refusal: <тот же текст notice> + top-level bridge_notice: + { + "executed": false, + "code": "BRIDGE_TOOL_NOT_EXECUTED", + "kind": "schema|format|unknown_tool|repeat|…", + "tool": "<имя или null>", + "message": "<полный notice>", + "as_tool_error": { + "name": "<имя или tool>", + "content": "NOT executed: …" + } + } + исполняемые tool_calls НЕ выдумываются + → следующий ход человека в агенте: + мост → провайдеру в ту же беседу: + Tool result (<имя>): + + <текст follow-up пользователя> +``` + +### Краткая таблица + +| Режим | Исход (если auto + stop + conversation_id) | +|-------|--------------------------------------------| +| `error` | `invalid_tool_response` агенту (fail-closed; дефолт) | +| `text` | content + `BRIDGE_TOOL_NOT_EXECUTED` + `bridge_notice` + `refusal`; следующий ход в провайдер как `Tool result (имя): …` | + +Иначе (required / нет id / не stop) — всегда raise, даже при `text`. +Совет browser (demote → `` ```text ``) в параметр **не** входит. Битый JSON с +известным name — входит (как `ToolCallError`). + +--- + +## Главное отличие парсеров (по `TOOLCALL_LEGACY`) + +| | `LEGACY=1` (`tool_protocol`) | `LEGACY=0` (`tool_protocol_new`, все провайдеры) | +|--|----------------------------|-----------------------------------------------| +| Блоки | Один протокол-блок (допуски DeepSeek) | Несколько верхнеуровневых `` ```tool_calls `` где угодно | +| Битый JSON / без имени tool | Обычно `ToolCallError` | Demote → `` ```text ``; иначе `ToolCallError` | +| Unknown tool + проза | Ошибка пакета | Demote → `` ```text `` (совет, не вызов) | +| Unknown / schema / битый JSON с известным name | Ошибка пакета | Ошибка всего сообщения (± repair / INVALID_MODE) | +| После неудачного repair / без repair | Всегда error | `TOOLCALL_INVALID_MODE` | +| Повтор executed call | — | `text` → notice; `error` → raise | + +--- + +## 1) Когда ответ идёт в tool-обработку (не сырая трансляция) + +### Вход в конвейер + +Как и раньше — по **запросу**, не по эвристике текста: + +- есть `req.tools` → `api_new.tool_chat_or_notice` → `tool_chat_with_retry` → (опц. demote → text) иначе `validate_tool_reply` → `tool_protocol_new.validate_reply`; +- ответ буферизуется; агенту — уже `tool_calls` / `content` / notice / ошибка; +- без `tools` → `_plain_conversation_reply`. + +Дальше развилка по провайдеру внутри `validate_reply`: + +```text +provider == deepseek? + да → deepseek parse (один блок, preamble, calls-alias, DSML closers, …) + нет и browser → _validate_browser_reply (find_tool_blocks) + иначе → deepseek-подобный parse без deepseek-допусков +``` + +### Browser: что считается вызовом + +Только **верхнеуровневый** fenced-блок `` ```tool_calls `` (`find_tool_blocks`: построчный учёт fence, вложенный/цитируемый fence не считается). + +- несколько таких блоков → все валидные вызовы по порядку; текст вокруг с вырезанными блоками → `content`; +- проза, `` ```json ``, массивы без fence, цитаты → текст; +- **до validate** (`demote_mistagged_tool_reply`, stop + auto): нет call-level `name` или unknown `name` + проза → `` ```tool_calls `` → `` ```text ``, успешный `content` (не repair / не INVALID_MODE); +- любой оставшийся битый/no_name/unknown/schema/format блок → `ToolCallError` на всё сообщение (silent `continue` на syntax/no_name убран); +- `tool_choice: none` → разбор блоков никогда не превращает ответ в ошибку. + +### DeepSeek: что считается вызовом + +Канон — один блок: + +````text +```tool_calls +[{"name": "...", "arguments": { ... }}] +``` +```` + +При штатном `stop`: preamble, алиас `` ```calls ``, узкие локальные починки оболочки (без угадывания аргументов). +«Похожая» разметка без канона (`has_tool_call_markup`) → ошибка, не текст. +Пустой ответ → ошибка. + +--- + +## 2) Ошибка → при `TOOLCALL_FORMAT_REPAIR=1` уходит промпт на переделку + +### Eligibility (все сразу) + +В `_tool_chat_with_retry`: + +1. `TOOLCALL_FORMAT_REPAIR=1`; +2. провайдер **DeepSeek** **или** browser-провайдер; +3. `tool_choice != "none"`; +4. `finish_reason == "stop"`; +5. есть `conversation_id`; +6. выброшен `ToolCallError`. + +Один paced-запрос в ту же беседу. Рекурсии нет. Исходный пакет не исполняется. + +**Важно для browser:** совет (нет `name` / unknown+проза) срезается **demote** до +validate → repair не запускается. Оставшиеся `syntax`/`no_name` в validate — +`ToolCallError` → eligible repair / INVALID_MODE. + +### Промпт DeepSeek + +Базовый `_FORMAT_REPAIR_PROMPT` (как раньше): + +```text +No tools were executed from your previous reply: it was empty or its tool-call format was invalid. Choose the next response using the task and tool schemas already provided. If a tool is necessary, return ONLY one fenced tool_calls block containing a complete valid JSON array. Each entry must have name and arguments; ALL input fields belong inside the arguments object. Escape string values correctly. No commentary before or after the block, and no XML/DSML tags. If you intended only an explanation or the work is complete, give plain text without a tool_calls block. Do not claim an unexecuted action succeeded. +``` + +При JSON-ошибке может добавиться `repair_hint` (позиция/категория, без значений аргументов). + +### Промпт (`api_new.browser_repair_prompt`) — pointed, без полного dump схем каждый раз + +| `exc.kind` | Что уходит модели | +|------------|-------------------| +| `unknown_tool` | «`X` is not an available tool» + полный `tool_catalog(tools)` (имена + форма аргументов, `?` = optional) + `_BROWSER_REPAIR_TAIL` | +| `schema` / `format` + есть `guidance` | `guidance` (форма **этого** инструмента, без значений) + tail | +| иначе | короткий «empty or invalid» + tail | + +Хвост `_BROWSER_REPAIR_TAIL`: + +```text +Send a call as a fenced tool_calls block: a JSON array of {"name": ..., "arguments": {...}} entries with exact tool names; all input fields belong inside arguments. If no action is needed, answer in plain text. Do not claim an unexecuted action succeeded. +``` + +Если коррекция успешна — агент видит только итоговый результат, **без** notice. + +--- + +## 3) Ошибка → даже при `TOOLCALL_FORMAT_REPAIR=1` промпт не шлётся + +### Сразу без correction-запроса + +| Признак | Поведение | +|--------|-----------| +| `TOOLCALL_FORMAT_REPAIR=0` | Repair выключен | +| Не DeepSeek и не browser | Не eligible | +| `tool_choice == "none"` | Не eligible | +| `finish_reason != "stop"` (например `length`) | Не format repair; возможен отдельный `TOOLCALL_MAX_CONTINUATIONS` | +| Нет `conversation_id` | Не eligible | +| Provider reject / login / cancel | Другой путь | +| Browser: блок `syntax` / `no_name` | Вообще не ошибка → текст | +| Уже была одна коррекция, второй ответ снова плохой | Дальше см. §4 (DeepSeek vs browser) | + +### После неудачного repair / без repair: `TOOLCALL_INVALID_MODE` + +Не зависит от DeepSeek vs browser — см. схему в разделе `TOOLCALL_INVALID_MODE` выше. + +- **`error` (default)** — `invalid_tool_response` агенту. +- **`text`** + guards — content notice с `BRIDGE_TOOL_NOT_EXECUTED`, `bridge_notice`, + `refusal`; чат провайдера жив; следующий ход → `Tool result (имя): …`. + +### Повтор вызова (executed-path, сейчас browser) + +Повтор с теми же args не исполняется: при `text` — notice `kind=repeat`; при `error` — raise. + +--- + +## 4) Полная схема развилок (`LEGACY=0`) + +Активна при `TOOLCALL_LEGACY=0` (`api_new` + `tool_protocol_new`). При +`TOOLCALL_LEGACY=1` (default) — main-era в `api.py` / `tool_protocol.py`; +см. [toolcall-format-repair-legacy.md](toolcall-format-repair-legacy.md). Флаг временный. + +```text +req.tools? + нет → plain reply + да → api_new.tool_chat_or_notice + │ + └─ tool_chat_with_retry + │ + ├─ chat провайдера + │ + ├─ [любой провайдер, stop, tool_choice auto] demote_mistagged_tool_reply? + │ да (совет, не вызов): + │ · нет call-level name (JSON целый/битый), или + │ · unknown name + проза вокруг fence + │ → ```tool_calls → ```text + │ → агенту успешный content + │ (без validate / repair / INVALID_MODE) + │ + └─ иначе → validate_tool_reply + │ + ├─ length + openai_format.looks_truncated (незакрытый top-level + │ ```tool_calls где угодно) + │ → TOOLCALL_MAX_CONTINUATIONS (НЕ format repair) + │ + ├─ tool_protocol_new.validate_reply (все провайдеры) + │ ├─ все валидные блоки → tool_calls + content вокруг + │ └─ syntax / no_name / unknown_tool / schema / format + │ → ToolCallError (advice уже срезан demote выше) + │ (tool_choice=none → блоки как текст, без ошибки) + │ + └─ ToolCallError + ├─ eligible + FORMAT_REPAIR=1 + │ → browser_repair_prompt (catalog / shape) + │ → один запрос → validate снова + │ ├─ OK → агенту итог (без notice) + │ └─ снова ошибка ↓ + └─ не eligible / второй сбой + ├─ INVALID_MODE=error или нет guards + │ → invalid_tool_response агенту + └─ INVALID_MODE=text + auto + stop + conversation_id + → агенту: content + BRIDGE_TOOL_NOT_EXECUTED + + bridge_notice + refusal + → провайдерский тред жив + → следующий user-ход → Tool result (имя): … + +Дополнительно (browser executed-path): + повтор name+args → text: notice repeat (+ bridge_notice) | error: raise + +Рядом: + ProviderRejected / login / cancel + context rollover (DeepSeek, opt-in) — без repair/continuation + TOOLCALL_DIAGNOSTICS / TOOLCALL_DEBUG + TOOL_INSTRUCTIONS_BROWSER=medium — длиннее reminder на продолжениях (не repair) +``` + +### Краткая схема repair + invalid mode + +```text +ToolCallError после штатного stop? + нет → (browser: возможно просто текст / tool_calls; или continuation; или reject) + да → eligible (DeepSeek|browser, FORMAT_REPAIR=1, tool_choice≠none, conversation_id)? + да → один correction → validate + ├─ OK → агенту + └─ снова ошибка → INVALID_MODE + нет → INVALID_MODE сразу + error → invalid_tool_response агенту + text + guards → content + bridge_notice + refusal + → next user → Tool result (имя): … в провайдер +``` + +--- + +## 5) Виды `ToolCallError.kind` (важно для browser) + +| kind | Смысл | Browser validate | Repair prompt | +|------|--------|------------------|---------------| +| `syntax` | Битый JSON блока | → текст | не доходит | +| `no_name` | Нет имени tool / пустой образец | → текст | не доходит | +| `unknown_tool` | Имя не из списка | ошибка | catalog всех tools | +| `format` | Оболочка/args не object и т.п. | ошибка | shape этого tool (`guidance`) | +| `schema` | JSON Schema | ошибка | shape этого tool | +| `repeat` | Повтор только что исполненного | notice (API-слой) | не format-repair | + +--- + +## 6) Переменные окружения + +| Переменная | Default | Роль при `LEGACY=0` | +|------------|---------|---------------------| +| `TOOLCALL_LEGACY` | `1` | Должен быть `0` для этого пути | +| `TOOLCALL_FORMAT_REPAIR` | `0` | Одна pointed-коррекция для **любого** провайдера | +| `TOOLCALL_INVALID_MODE` | `error` | `error` / `text` — исход невалидности для **любого** провайдера | +| `TOOLCALL_MAX_CONTINUATIONS` | `0` | Continuation при `length` (незакрытый top-level fence где угодно) | +| `TOOL_INSTRUCTIONS_BROWSER` | `light` | `medium` — формы аргументов на продолжениях беседы | +| `TOOLCALL_DIAGNOSTICS` / `TOOLCALL_DEBUG` | off | Диагностика, не меняют дерево решений | + +--- + +## Связь с соседними файлами + +| Файл | Смысл | +|------|--------| +| [`toolcall-format-repair-legacy.md`](toolcall-format-repair-legacy.md) | `LEGACY=1` / main-era (`api` + `tool_protocol`) | +| [`toolcall-format-repair.md`](toolcall-format-repair.md) | Этот файл: единый `LEGACY=0` (`api_new` + `tool_protocol_new`) | diff --git a/providers/access.py b/providers/access.py index b89eba2..d59232c 100644 --- a/providers/access.py +++ b/providers/access.py @@ -25,6 +25,17 @@ MAX_BUDGET = 2000 PROVIDERS = frozenset(('deepseek', 'qwen', 'grok', 'mistral', 'kimi', 'glm', 'gemini', 'chatgpt', 'claude')) + + +def _access_pause_enabled() -> bool: + """Whether durable provider errors block future attempts; default on. + + ``PROVIDER_ACCESS_PAUSE=0`` ignores persisted pause/pending blocks and does + not latch new provider failures. Provider/account quotas, local budgets, + active-attempt ownership, and conversation-history safety checks remain. + This opt-out can retry after an uncertain outcome and may duplicate requests. + """ + return os.getenv('PROVIDER_ACCESS_PAUSE', '1') == '1' MESSAGES = { 'local_budget_exceeded': 'Local request budget exhausted. No automatic retry; limits survive restart and resume.', 'local_prompt_too_large': 'The prompt exceeds the local per-request size limit (128 KiB). Shorten the context; waiting or resetting budgets does not help.', @@ -297,8 +308,11 @@ def _decode(state): raise ValueError('Invalid safety state') records = {} for provider, item in state.items(): + # Ignore records for removed providers. Preserve active provider + # records, but do not let stale keys make the whole safety file + # unavailable after a provider is retired or renamed. if provider not in PROVIDERS: - raise ValueError('Unknown provider') + continue # Upgrade the initial flat pause format without losing restrictions. if isinstance(item, str) and item in MESSAGES: item = {'pause': item} @@ -375,12 +389,14 @@ def _save(self, state): def check(self, provider, owner=None, queued=False): if provider not in PROVIDERS: raise ValueError('Unknown provider') - if provider in self._failed: + if _access_pause_enabled() and provider in self._failed: raise ProviderRejected('safety_state_unavailable') active = current_attempt(provider) owner = owner or (active.id if active is not None and active.guard is self else None) with self._state() as state: record = state.get(provider, {}) + if not _access_pause_enabled(): + return if record.get('pause'): raise ProviderRejected(record['pause'], source='local_pause') if owner is not None and record.get('pending') != owner: @@ -394,11 +410,14 @@ def begin(self, provider, cancelled=None): self.check(provider) with self._state() as state: record = state.setdefault(provider, {}) - if record.get("pause") or record.get("pending"): - raise ProviderRejected( - record.get("pause") or "upstream_failure", - source='local_pause' if record.get("pause") else 'upstream', - ) + if _access_pause_enabled(): + paused = bool(record.get("pause")) + pending = bool(record.get("pending")) + if paused or pending: + raise ProviderRejected( + record.get("pause") or "upstream_failure", + source='local_pause' if paused else 'upstream', + ) record.update(pending=attempt.id, dispatched=False) try: self._save(state) # Write-ahead: failure here prohibits all dispatch. @@ -447,6 +466,9 @@ def wait(self, provider, cancelled=None): def reject(self, provider, exc, owner=None): error = exc if isinstance(exc, ProviderRejected) else rejection( exc, status=exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None) + if not _access_pause_enabled(): + # Testing opt-out: fail this request without a durable pause latch. + return error newly_paused = False try: with self._state() as state: @@ -525,6 +547,8 @@ def reset_budget(self, revision): def status(self): with self._state() as state: + if not _access_pause_enabled(): + return {} return {name: ('safety_state_unavailable' if name in self._failed else record.get('pause') or 'upstream_failure') for name, record in state.items() if name in self._failed or record.get('pause') or record.get('pending')} @@ -587,11 +611,17 @@ def dispatch(self, validate=None, kind="http"): validate() with self.guard._state() as state: record = state[self.provider] - if record.get('pending') != self.id or record.get('pause'): - raise ProviderRejected( - record.get('pause') or 'upstream_failure', - source='local_pause' if record.get('pause') else 'upstream', - ) + if _access_pause_enabled(): + paused = bool(record.get('pause')) + if record.get('pending') != self.id or paused: + raise ProviderRejected( + record.get('pause') or 'upstream_failure', + source='local_pause' if paused else 'upstream', + ) + elif record.get('pending') != self.id: + # Even with the opt-out, an individual live attempt must + # still own its write-ahead dispatch record. + raise ProviderRejected('upstream_failure') delay = 0 if self.dispatched else record.get('next_at', 0) - time.time() if self.provider == 'deepseek': delay = max(delay, record.get('next_http_at', 0) - time.time()) diff --git a/server/api.py b/server/api.py index 34a8c00..2aef3a1 100644 --- a/server/api.py +++ b/server/api.py @@ -27,13 +27,14 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import os +import re import threading import time from contextlib import asynccontextmanager -import hashlib from contextvars import ContextVar import anyio @@ -54,6 +55,12 @@ from .tool_protocol import validate_reply from .tool_history import validate_tool_history from .diagnostics import save_tool_failure +from .toolcall_logging import ( + enabled as toolcall_logging_enabled, + new_chat as new_toolcall_log, + record as record_toolcall_log, + record_error as record_toolcall_error, +) from .conversation_cache import conversation_cache, ConversationStateError from .config import ( @@ -69,11 +76,11 @@ is_known_model, resolve_model_type, model_provider, + provider_uses_browser, qwen_uses_browser, ) from .openai_format import ( completion_response, - looks_truncated, messages_to_prompt, sse_frames, stream_chunks, @@ -86,6 +93,18 @@ _log = logging.getLogger(__name__) +# Main-era continuation probe (as in openai_format at 60f860a). openai_format.looks_truncated +# is now mid-message for TOOLCALL_LEGACY=0 / api_new; keep start-of-reply here for LEGACY=1. +_TOOL_START_RE = re.compile(r"\A```tool_calls[ \t]*\r?\n") +_CLOSING_TOOL_FENCE_RE = re.compile(r"(?m)^```[ \t]*\r?$") + + +def looks_truncated(text: str, allowed_names=None) -> bool: + """Continue only an explicitly started tool block missing its closing fence.""" + del allowed_names + stripped = (text or "").strip() + return bool(_TOOL_START_RE.match(stripped)) and not _CLOSING_TOOL_FENCE_RE.search(stripped) + # One shared client (and its signed-in session) built lazily on first use. _client: DeepSeekClient | None = None _client_identity = None @@ -286,9 +305,13 @@ def _invalidate_client(provider, client=None): def _chat_with_retry(prompt: str, req: ChatCompletionRequest, model_type): """One durable attempt; cancellation cannot erase an uncertain dispatch.""" + trace = req._toolcall_log_path provider = model_provider(req.model) if provider == 'deepseek' and len(prompt.encode('utf-8')) > 131072: - raise ProviderRejected('local_prompt_too_large', source='local_limit') + record_toolcall_log(trace, "to_provider", prompt) + error = ProviderRejected('local_prompt_too_large', source='local_limit') + record_toolcall_error(trace, "from_provider", error) + raise error client = None attempt = None try: @@ -310,8 +333,12 @@ def _chat_with_retry(prompt: str, req: ChatCompletionRequest, model_type): raise QwenLoginRequired('The Qwen session belongs to a different account than this history; ' 'no request was sent.') attempt.check() # Recheck a pause that happened while building a client. - return client.chat(prompt, req.conversation_id, model_type, req.thinking, req.search) - except LoginRequired: + record_toolcall_log(trace, "to_provider", prompt) + reply = client.chat(prompt, req.conversation_id, model_type, req.thinking, req.search) + record_toolcall_log(trace, "from_provider", reply.text) + return reply + except LoginRequired as exc: + record_toolcall_error(trace, "from_provider", exc) _invalidate_client(provider, client) raise except Exception as exc: @@ -319,6 +346,7 @@ def _chat_with_retry(prompt: str, req: ChatCompletionRequest, model_type): if attempt is not None: _invalidate_client(provider, client) error = exc if isinstance(exc, ProviderRejected) else rejection(exc) + record_toolcall_error(trace, "from_provider", error) raise error from exc @@ -331,7 +359,13 @@ def _chat_with_retry(prompt: str, req: ChatCompletionRequest, model_type): # One opt-in format correction in the same DeepSeek conversation. This is # separate from transport retries and output-limit continuation; neither a # rejection nor an uncertain/truncated generation qualifies. +# With TOOLCALL_LEGACY=0, repair uses api_new.browser_repair_prompt for every provider. _FORMAT_REPAIR = os.getenv("TOOLCALL_FORMAT_REPAIR", "0") == "1" + +# Temporary switch: 1 = main-era path in this file + tool_protocol.py (default); +# 0 = tool-rework in api_new.py + tool_protocol_new.py. Will be removed. +_LEGACY = os.getenv("TOOLCALL_LEGACY", "1") == "1" + _FORMAT_REPAIR_PROMPT = ( "No tools were executed from your previous reply: it was empty or its tool-call format was invalid. " "Choose the next response using the task and tool schemas already provided. " @@ -455,10 +489,13 @@ def _conversation_tool_chat(prompt, req, model_type): else: scope = _conversation_scope(provider) conversation_token = _bound_conversation.set(binding) - req, steps = conversation_cache.prepare(req, scope=scope, claim=claim, - legacy_scopes=legacy, - allow_local_rollover=(provider == 'deepseek' and - os.getenv('DEEPSEEK_CONTEXT_ROLLOVER', '0') == '1')) + browser = provider != "deepseek" and provider_uses_browser(provider) + req, steps = conversation_cache.prepare( + req, scope=scope, claim=claim, compact_results=browser, + legacy_scopes=legacy, + allow_local_rollover=(provider == 'deepseek' and + os.getenv('DEEPSEEK_CONTEXT_ROLLOVER', '0') == '1'), + trace_path=original._toolcall_log_path) if claim and steps >= conversation_cache.max_replies: # Migrate before dispatch: the capped conversation receives no request. # A failure in the new conversation cannot enter the refusal-retry path. @@ -477,8 +514,14 @@ def _conversation_tool_chat(prompt, req, model_type): conversation_cache.claim_rollover(claim, req.conversation_id, archive) _check_cancelled() reply = _chat_with_retry(migration_prompt, migrated, resolve_model_type(original.model)) - result = (_validate_tool_reply(reply, migrated, allow_continuation=False) if migrated.tools else - (reply, reply.text, None)) + if not migrated.tools: + result = (reply, reply.text, None) + elif not _LEGACY: + from . import api_new + result = api_new.validate_tool_reply( + reply, migrated, allow_continuation=False) + else: + result = _validate_tool_reply(reply, migrated, allow_continuation=False) # Only the new conversation starts at one reply. Old rows and all # provider usage journals remain intact. conversation_cache.remember(original, *result, 0, scope=scope) @@ -488,11 +531,18 @@ def _conversation_tool_chat(prompt, req, model_type): # Continuation of a saved provider chat: browser light mode may shorten tools. prompt = messages_to_prompt( req.messages, req.tools, req.tool_choice, - tools_continuation=True, provider=provider) + tools_continuation=True, provider=provider, tool_results_compact=browser) model_type = None try: - result = (_tool_chat_with_retry(prompt, req, model_type) if req.tools else - _plain_conversation_reply(prompt, req, model_type)) + if not req.tools: + result = _plain_conversation_reply(prompt, req, model_type) + elif not _LEGACY: + from . import api_new + result = api_new.tool_chat_or_notice( + prompt, req, model_type, provider, + api_new.executed_calls(original.messages)) + else: + result = _tool_chat_with_retry(prompt, req, model_type) except ProviderRejected as exc: # One opt-in migration, never a transport retry or a format repair. # Existing pauses cannot enter here: only this request's first, @@ -521,8 +571,14 @@ def _conversation_tool_chat(prompt, req, model_type): # No format-repair or output continuation on the migration request. # Any new failure stops with the new attempt still consumed. migrated_reply = _chat_with_retry(migration_prompt, migrated, resolve_model_type(original.model)) - result = (_validate_tool_reply(migrated_reply, migrated, allow_continuation=False) if migrated.tools else - (migrated_reply, migrated_reply.text, None)) + if not migrated.tools: + result = (migrated_reply, migrated_reply.text, None) + elif not _LEGACY: + from . import api_new + result = api_new.validate_tool_reply( + migrated_reply, migrated, allow_continuation=False) + else: + result = _validate_tool_reply(migrated_reply, migrated, allow_continuation=False) conversation_cache.remember(original, *result, steps, scope=scope) succeeded = True return result @@ -545,6 +601,7 @@ def _conversation_tool_chat(prompt, req, model_type): def _tool_chat_with_retry(prompt: str, req: ChatCompletionRequest, model_type): + """Main-era path (TOOLCALL_LEGACY=1): DeepSeek-only format repair; always raise.""" reply = _chat_with_retry(prompt, req, model_type) try: return _validate_tool_reply(reply, req) @@ -643,6 +700,7 @@ def _sse_error(message: str, err_type: str = "server_error") -> str: def _provider_pause_message(exc: ProviderRejected, model: str) -> str: """Client-facing pause text. Never includes upstream bodies or secrets.""" provider = model_provider(model) + pause_enabled = os.getenv('PROVIDER_ACCESS_PAUSE', '1') == '1' resume = f"python -m providers.access resume {provider}" if exc.code in ('account_restricted', 'account_muted', 'account_banned', 'ip_restricted', 'gateway_challenge', 'pow_rejected'): @@ -667,6 +725,13 @@ def _provider_pause_message(exc: ProviderRejected, model: str) -> str: f"No request was sent to the upstream provider. " f"Inspect the normal website, then clear the pause with: `{resume}`" ) + if not pause_enabled: + return ( + f"Upstream provider `{provider}` refused this request " + f"(mapped={exc.code}): {exc} " + "Provider blocking is disabled (`PROVIDER_ACCESS_PAUSE=0`); " + "no manual pause reset is required." + ) # Fresh allowlisted upstream refusal that just latched a durable pause. return ( f"Upstream provider `{provider}` refused this request " @@ -710,6 +775,13 @@ async def _tool_stream(req: ChatCompletionRequest, prompt: str, model_type): can take minutes for a large write/edit. We run that work as a task and emit SSE keep-alive comments while it runs so the client's idle timer never fires. """ + trace = req._toolcall_log_path + if trace is None and req.conversation_id and toolcall_logging_enabled(): + trace = conversation_cache.trace_path(req.conversation_id) + req._toolcall_log_path = trace + if trace is None and toolcall_logging_enabled() and not req.conversation_id: + trace = new_toolcall_log(model_provider(req.model)) + req._toolcall_log_path = trace task = asyncio.ensure_future(_run_tool_chat(prompt, req, model_type)) try: while True: @@ -721,20 +793,37 @@ async def _tool_stream(req: ChatCompletionRequest, prompt: str, model_type): try: reply, content, tool_calls = task.result() except LoginRequired as e: - yield _sse_error(str(e), "login_required") + record_toolcall_error(trace, "from_provider", e) + frame = _sse_error(str(e), "login_required") + record_toolcall_log(trace, "to_agent", frame) + yield frame return except ToolCallError as e: - yield _sse_error(str(e), "invalid_tool_response") + record_toolcall_error(trace, "from_provider", e) + frame = _sse_error(str(e), "invalid_tool_response") + record_toolcall_log(trace, "to_agent", frame) + yield frame return except ProviderRejected as e: - yield _sse_error(_provider_pause_message(e, req.model), e.code) + record_toolcall_error(trace, "from_provider", e) + frame = _sse_error(_provider_pause_message(e, req.model), e.code) + record_toolcall_log(trace, "to_agent", frame) + yield frame return except Exception as e: - yield _sse_error(_completion_failure_message(e)) + record_toolcall_error(trace, "from_provider", e) + frame = _sse_error(_completion_failure_message(e)) + record_toolcall_log(trace, "to_agent", frame) + yield frame return - for frame in sse_frames(req.model, content, tool_calls, reply.conversation_id, - finish_reason=reply.finish_reason): + if trace is not None and reply.conversation_id: + conversation_cache.remember_trace(reply.conversation_id, trace) + frames = sse_frames(req.model, content, tool_calls, reply.conversation_id, + finish_reason=reply.finish_reason, + bridge_notice=getattr(reply, "bridge_notice", None)) + for frame in frames: + record_toolcall_log(trace, "to_agent", frame) yield frame finally: if not task.done(): @@ -754,6 +843,7 @@ def _stream_with_retry(client: DeepSeekClient, prompt: str, with attempt.bind(): client = _request_client(req) attempt.check() + record_toolcall_log(req._toolcall_log_path, "to_provider", prompt) stream = _open_stream(client, prompt, req, model_type) stream.access_attempt = attempt iterator = iter(stream) @@ -766,16 +856,44 @@ def remaining(): # Verify and clear the journal before yielding final SSE frames. attempt.complete() - yield from stream_chunks(req.model, stream, iterator=remaining()) + trace = req._toolcall_log_path + reply_chunks = [] + first_frame = True + for chunk in remaining(): + if not chunk: + continue + reply_chunks.append(chunk) + record_toolcall_log(trace, "from_provider", chunk) + for frame in stream_chunks( + req.model, stream, + iterator=([chunk] if first_frame else ()), + include_role=first_frame, include_final=False): + record_toolcall_log(trace, "to_agent", frame) + yield frame + first_frame = False + record_toolcall_log(trace, "from_provider", "".join(reply_chunks)) + for frame in stream_chunks(req.model, stream, iterator=(), + include_role=first_frame, include_final=True): + record_toolcall_log(trace, "to_agent", frame) + yield frame + except LoginRequired as exc: _invalidate_client(model_provider(req.model), client) - yield _sse_error(str(exc), "login_required") + trace = req._toolcall_log_path + record_toolcall_error(trace, "from_provider", exc) + frame = _sse_error(str(exc), "login_required") + record_toolcall_log(trace, "to_agent", frame) + yield frame except Exception as exc: if attempt is not None: attempt.abort(exc) _invalidate_client(model_provider(req.model), client) error = exc if isinstance(exc, ProviderRejected) else rejection(exc) - yield _sse_error(_provider_pause_message(error, req.model), error.code) + trace = req._toolcall_log_path + record_toolcall_error(trace, "from_provider", error) + frame = _sse_error(_provider_pause_message(error, req.model), error.code) + record_toolcall_log(trace, "to_agent", frame) + yield frame finally: if iterator is not None and hasattr(iterator, "close"): iterator.close() @@ -793,6 +911,7 @@ async def _plain_stream(client, prompt, req, model_type): frame = await _run_worker(next, generator, _MISSING) if frame is _MISSING: break + record_toolcall_log(req._toolcall_log_path, "to_agent", frame) yield frame finally: with anyio.CancelScope(shield=True): @@ -801,6 +920,13 @@ async def _plain_stream(client, prompt, req, model_type): async def _buffered_plain_stream(req, prompt, model_type): """Keep the connection alive while optional transports validate an answer.""" + trace = req._toolcall_log_path + if trace is None and req.conversation_id and toolcall_logging_enabled(): + trace = conversation_cache.trace_path(req.conversation_id) + req._toolcall_log_path = trace + if trace is None and toolcall_logging_enabled() and not req.conversation_id: + trace = new_toolcall_log(model_provider(req.model)) + req._toolcall_log_path = trace task = asyncio.ensure_future(_run_chat_with_retry(prompt, req, model_type)) try: while True: @@ -811,16 +937,29 @@ async def _buffered_plain_stream(req, prompt, model_type): try: reply = task.result() except LoginRequired as exc: - yield _sse_error(str(exc), "login_required") + record_toolcall_error(trace, "from_provider", exc) + frame = _sse_error(str(exc), "login_required") + record_toolcall_log(trace, "to_agent", frame) + yield frame return except ProviderRejected as exc: - yield _sse_error(_provider_pause_message(exc, req.model), exc.code) + record_toolcall_error(trace, "from_provider", exc) + frame = _sse_error(_provider_pause_message(exc, req.model), exc.code) + record_toolcall_log(trace, "to_agent", frame) + yield frame return except Exception as exc: - yield _sse_error(_completion_failure_message(exc)) + record_toolcall_error(trace, "from_provider", exc) + frame = _sse_error(_completion_failure_message(exc)) + record_toolcall_log(trace, "to_agent", frame) + yield frame return - for frame in sse_frames(req.model, reply.text, None, reply.conversation_id, - finish_reason=reply.finish_reason): + if trace is not None and reply.conversation_id: + conversation_cache.remember_trace(reply.conversation_id, trace) + frames = sse_frames(req.model, reply.text, None, reply.conversation_id, + finish_reason=reply.finish_reason) + for frame in frames: + record_toolcall_log(trace, "to_agent", frame) yield frame finally: if not task.done(): @@ -836,6 +975,10 @@ async def lifespan(app: FastAPI): _provider_request_gates = {name: asyncio.Lock() for name in PROVIDERS} _stop_refresh = threading.Event() stop_event = _stop_refresh + # CONVERSATION_CLEAR_USED_ON_STARTUP=1 removes consumed cache links. This + # allows repeating an initial prompt in a new chat, but a repeated history + # can be matched to the wrong upstream chat and fail during continuation. + conversation_cache.clear_used_links_on_startup() # Background browser recapture is retired, including existing .env opt-ins. try: yield @@ -915,27 +1058,51 @@ async def disconnected(): async def _chat_completions(req: ChatCompletionRequest): + trace = req._toolcall_log_path + if trace is None and toolcall_logging_enabled(): + if req.conversation_id: + trace = conversation_cache.trace_path(req.conversation_id) + if trace is None: + trace = new_toolcall_log(model_provider(req.model) if is_known_model(req.model) else req.model) + req._toolcall_log_path = trace + record_toolcall_log(trace, "from_agent", req.model_dump_json(exclude_none=True)) if not req.messages: - return _error("`messages` must not be empty", status=400, err_type="invalid_request_error") + response = _error("`messages` must not be empty", status=400, err_type="invalid_request_error") + record_toolcall_log(trace, "to_agent", response) + return response try: validate_tool_history(req.messages) except ValueError as exc: - return _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_error(trace, "from_agent", exc) + response = _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_log(trace, "to_agent", response) + return response if not is_known_model(req.model): - return _error( + record_toolcall_log(trace, "from_provider", f"UnknownModel: {req.model}") + response = _error( f"The model `{req.model}` does not exist. Available models: " f"{', '.join(advertised_model_ids())}", status=404, err_type="model_not_found", ) + record_toolcall_log(trace, "to_agent", response) + return response try: # A live request in this process may be queued behind; abandoned or # external attempts and real pauses must still fail before any factory. await _run_worker(guard.check, model_provider(req.model), None, True) except ProviderRejected as exc: - return _provider_rejected_response(exc, req.model) + record_toolcall_error(trace, "from_provider", exc) + response = _provider_rejected_response(exc, req.model) + record_toolcall_log(trace, "to_agent", response) + return response + except Exception as exc: + record_toolcall_error(trace, "from_provider", exc) + response = _error(_completion_failure_message(exc), status=500) + record_toolcall_log(trace, "to_agent", response) + return response if req.conversation_id: try: @@ -947,7 +1114,10 @@ async def _chat_completions(req: ChatCompletionRequest): else: decode_deepseek_cid(req.conversation_id) except ValueError as exc: - return _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_error(trace, "from_agent", exc) + response = _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_log(trace, "to_agent", response) + return response try: names = _tool_names(req.tools) @@ -955,7 +1125,10 @@ async def _chat_completions(req: ChatCompletionRequest): raise ValueError("Tools must have distinct, non-empty function names") validate_tool_choice(req.tool_choice, names) except ValueError as exc: - return _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_error(trace, "from_agent", exc) + response = _error(str(exc), status=400, err_type="invalid_request_error") + record_toolcall_log(trace, "to_agent", response) + return response # A thread's model is fixed when it's created, so on resume we ignore `model` # (the OpenAI SDK always sends one) and let the existing thread's model stand. @@ -975,18 +1148,39 @@ async def _chat_completions(req: ChatCompletionRequest): _tool_stream(req, prompt, model_type), media_type="text/event-stream", ) + record_toolcall_log(trace, "to_provider", prompt) try: reply, content, tool_calls = await _run_tool_chat(prompt, req, model_type) except LoginRequired as e: - return _error(str(e), status=401, err_type="login_required") + record_toolcall_error(trace, "from_provider", e) + response = _error(str(e), status=401, err_type="login_required") + record_toolcall_log(trace, "to_agent", response) + return response except ToolCallError as e: - return _error(str(e), status=400, err_type="invalid_tool_response") + record_toolcall_error(trace, "from_provider", e) + response = _error(str(e), status=400, err_type="invalid_tool_response") + record_toolcall_log(trace, "to_agent", response) + return response except ProviderRejected as e: - return _provider_rejected_response(e, req.model) + record_toolcall_error(trace, "from_provider", e) + response = _provider_rejected_response(e, req.model) + record_toolcall_log(trace, "to_agent", response) + return response except Exception as e: - return _error(_completion_failure_message(e), status=400) + record_toolcall_error(trace, "from_provider", e) + response = _error(_completion_failure_message(e), status=400) + record_toolcall_log(trace, "to_agent", response) + return response - return completion_response( + result = completion_response( req.model, content, prompt, reply.conversation_id, tool_calls, finish_reason=reply.finish_reason, + bridge_notice=getattr(reply, 'bridge_notice', None), ) + if trace is not None and reply.conversation_id: + conversation_cache.remember_trace(reply.conversation_id, trace) + elif trace is not None and req.conversation_id: + conversation_cache.remember_trace(req.conversation_id, trace) + + record_toolcall_log(trace, "to_agent", result) + return result diff --git a/server/api_new.py b/server/api_new.py new file mode 100644 index 0000000..08f72c5 --- /dev/null +++ b/server/api_new.py @@ -0,0 +1,196 @@ +"""TOOLCALL_LEGACY=0 tool-chat pipeline (demote, pointed repair, INVALID_MODE). + +Only functions that differ from or did not exist in the main-era api.py path. +Callers in api.py dispatch here when TOOLCALL_LEGACY=0. +""" + +from __future__ import annotations + +import json +import os + +from chat_protocol import Reply + +from .diagnostics import save_tool_failure +from .openai_format import ToolCallError, looks_truncated, tool_catalog +from .tool_notice import bridge_notice_payload, tool_failure_message +from .tool_protocol_new import demote_mistagged_tool_reply, validate_reply + +_INVALID_MODE = os.getenv("TOOLCALL_INVALID_MODE", "error").strip().lower() +if _INVALID_MODE not in ("error", "text"): + raise ValueError("TOOLCALL_INVALID_MODE must be 'error' or 'text'") + +_BROWSER_REPAIR_TAIL = ( + "Send a call as a fenced tool_calls block: a JSON array of " + "{\"name\": ..., \"arguments\": {...}} entries with exact tool names; all input " + "fields belong inside arguments. If no action is needed, answer in plain text. " + "Do not claim an unexecuted action succeeded." +) + + +def browser_repair_prompt(exc: ToolCallError, tools) -> str: + """Pointed correction for every provider (no per-turn schema dump).""" + if exc.kind == "unknown_tool": + shown = f"`{exc.tool}` is not an available tool" if exc.tool else "it named no available tool" + return (f"No tools were executed from your previous reply: {shown}. " + "Available tools and their argument shapes (`?` = optional; use exactly these " + "field names and nesting):\n" + tool_catalog(tools) + "\n" + _BROWSER_REPAIR_TAIL) + if exc.kind in ("schema", "format") and exc.guidance: + return exc.guidance + "\n" + _BROWSER_REPAIR_TAIL + return ("No tools were executed from your previous reply: it was empty or its tool call " + "was invalid.\n" + _BROWSER_REPAIR_TAIL) + + +def executed_calls(messages) -> list: + """Canonical (name, arguments) of the calls whose results this request carries.""" + last = max((i for i, m in enumerate(messages) if m.role == "assistant"), default=None) + if last is None or not messages[last].tool_calls: + return [] + if not any(m.role == "tool" for m in messages[last + 1:]): + return [] + out = [] + for call in messages[last].tool_calls: + function = (call or {}).get("function") or {} + out.append(canonical_call(function.get("name"), function.get("arguments"))) + return out + + +def canonical_call(name, arguments): + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except ValueError: + pass + return name, json.dumps(arguments, sort_keys=True, ensure_ascii=False) + + +def tool_chat_or_notice(prompt, req, model_type, provider: str, executed=()): + """Validate; optional bridge notice under TOOLCALL_INVALID_MODE=text.""" + from . import api + + auto = req.tool_choice in (None, "auto") + try: + result = tool_chat_with_retry(prompt, req, model_type) + except ToolCallError as exc: + failed = exc.reply + if (_INVALID_MODE != "text" + or not auto or failed is None + or failed.finish_reason != "stop" or not failed.conversation_id): + raise + print(f'[toolcalls] invalid reply returned as a bridge notice provider={provider}', + flush=True) + notice = bridge_notice_payload(exc) + return ( + Reply(failed.text, failed.conversation_id, failed.finish_reason, notice), + tool_failure_message(failed.text, exc), + None, + ) + reply, _content, calls = result + if calls and executed and auto and reply.conversation_id: + repeated = [canonical_call(c["function"]["name"], c["function"]["arguments"]) for c in calls] + if any(call in executed for call in repeated): + if _INVALID_MODE != "text": + repeated_error = ToolCallError("Repeated tool call", kind="repeat") + repeated_error.reply = reply + raise repeated_error + print(f'[toolcalls] repeated call not executed; returned as a bridge notice ' + f'provider={provider}', flush=True) + exc = ToolCallError("Repeated tool call", kind="repeat") + notice = bridge_notice_payload(exc) + return ( + Reply(reply.text, reply.conversation_id, reply.finish_reason, notice), + tool_failure_message(reply.text, exc), + None, + ) + return result + + +def tool_chat_with_retry(prompt, req, model_type): + """Demote + validate + pointed repair for every provider.""" + from . import api + + reply = api._chat_with_retry(prompt, req, model_type) + if (reply.finish_reason == "stop" + and req.tool_choice in (None, "auto")): + demoted = demote_mistagged_tool_reply(reply.text, req.tools) + if demoted is not None: + print('[toolcalls] mistagged tool_calls demoted to text', flush=True) + demoted_reply = Reply(demoted, reply.conversation_id, reply.finish_reason) + return demoted_reply, demoted, None + try: + return validate_tool_reply(reply, req) + except ToolCallError as initial_error: + if not (api._FORMAT_REPAIR + and req.tool_choice != "none" and reply.finish_reason == "stop" + and reply.conversation_id): + print('[toolcalls] format_correction=not_eligible', flush=True) + raise + api._check_cancelled() + correction = req.model_copy(update={"conversation_id": reply.conversation_id}) + print('[toolcalls] format_correction=requested', flush=True) + correction_prompt = browser_repair_prompt(initial_error, req.tools) + corrected = api._chat_with_retry(correction_prompt, correction, None) + if corrected.finish_reason != "stop": + print('[toolcalls] format_correction=incomplete', flush=True) + save_tool_failure(corrected, req) + raise ToolCallError("The format correction did not finish normally; no calls were returned") + try: + result = validate_tool_reply(corrected, req) + except ToolCallError as exc: + print('[toolcalls] format_correction=invalid', flush=True) + failure = ToolCallError( + 'The single format correction also failed; no further correction was requested. ' + + str(exc), repair_hint=exc.repair_hint, guidance=exc.guidance, + kind=exc.kind, tool=exc.tool) + failure.reply = exc.reply + raise failure from exc + print('[toolcalls] format_correction=validated', flush=True) + return result + + +def validate_tool_reply(reply: Reply, req, *, allow_continuation=True): + """LEGACY=0 validate + mid-message looks_truncated continuation.""" + from . import api + from deepseek.auth import LoginRequired + from providers.access import ProviderRejected + + names = api._tool_names(req.tools) + maximum = api._MAX_CONTINUATIONS if allow_continuation else 0 + for attempt in range(maximum + 1): + api._check_cancelled() + can_continue = (reply.finish_reason == "length" + and looks_truncated(reply.text, names)) + if not can_continue: + try: + content, tool_calls = validate_reply( + reply.text, req.tools, req.tool_choice, + finish_reason=reply.finish_reason, + provider=api.model_provider(req.model), + ) + except ToolCallError as exc: + exc.reply = reply + save_tool_failure(reply, req) + if api._toolcall_debug(): + print(f"[toolcalls] invalid explicit reply:\n{reply.text}\n", flush=True) + raise + if tool_calls is None and api._toolcall_debug(): + print(f"[toolcalls] no call parsed; raw reply:\n{reply.text}\n", flush=True) + return reply, content, tool_calls + if req.tool_choice == "none": + raise ToolCallError("The model started a tool call while tool_choice='none'") + if attempt == maximum: + raise ToolCallError("The provider reached its output limit before completing the tool reply; " + "no calls were returned and no further continuation is allowed") + print( + f"[toolcalls] reply truncated (attempt {attempt + 1}/" + f"{api._MAX_CONTINUATIONS}); requesting continuation...", + flush=True, + ) + try: + nxt = api._continue_reply(reply, req) + except (LoginRequired, ProviderRejected): + raise + except Exception as e: + raise ToolCallError("Could not finish the truncated tool reply") from e + reply = Reply(text=reply.text + nxt.text, conversation_id=nxt.conversation_id, + finish_reason=nxt.finish_reason) diff --git a/server/config.py b/server/config.py index 808afb2..1373cac 100644 --- a/server/config.py +++ b/server/config.py @@ -4,6 +4,7 @@ import json import re +from providers.access import PROVIDERS from settings import load_environment from providers.kimi_http import direct_enabled from providers.kimi_models import PUBLIC_MODELS as KIMI_MODELS @@ -23,13 +24,15 @@ def _transport(name, default, allowed): def _tool_instructions_mode(name, default): value = os.getenv(name, default).strip().lower() or default - if value not in ("full", "light", "none"): - raise ValueError(f"{name} must be full, light, or none") + if value not in ("full", "medium", "light", "none"): + raise ValueError(f"{name} must be full, medium, light, or none") return value # How OpenAI `tools` are flattened into provider prompts. # full — complete schemas + rules on every turn. +# medium — like light, but the reminder also lists each tool's argument shape +# (field names, nesting; no descriptions). # light — full block on the first provider-chat turn; short reminder afterward # (recommended for browser tabs). # none — like light on the first turn; no tools block on continuations. @@ -204,8 +207,19 @@ def canonical_model(name: str): def model_provider(name: str) -> str: + """Resolve a model's provider; preserve known provider IDs when disabled.""" bare = canonical_model(name) - return _bare_owner(bare if bare is not None else name) + if bare is not None: + return _bare_owner(bare) + if isinstance(name, str) and "/" in name: + owner, _, bare = name.partition("/") + if bare in MODEL_MAP and _bare_owner(bare) == owner: + return owner + if isinstance(name, str) and name.endswith("-web"): + provider = name[:-4] + if provider in PROVIDERS: + return provider + return _bare_owner(name) def is_known_model(name: str) -> bool: diff --git a/server/conversation_admin.py b/server/conversation_admin.py new file mode 100644 index 0000000..85ac218 --- /dev/null +++ b/server/conversation_admin.py @@ -0,0 +1,43 @@ +"""Administrative commands for the local conversation cache.""" + +import argparse +import json + +from .conversation_cache import conversation_cache + + +def clear_all(cache=conversation_cache): + """Delete cached conversation links and associated local metadata.""" + with cache.connection() as db: + counts = {} + for table in ('links', 'trace_links', 'rollovers'): + exists = db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() + if exists: + cursor = db.execute(f'DELETE FROM {table}') + counts[table] = cursor.rowcount + else: + counts[table] = 0 + return counts + + +def main(argv=None): + parser = argparse.ArgumentParser( + description='Manage local cached conversation links and trace associations.' + ) + parser.add_argument( + '-clear', '--clear', action='store_true', dest='clear', + help='delete all cached chats, trace associations, and rollover records', + ) + args = parser.parse_args(argv) + if not args.clear: + parser.print_help() + return 0 + counts = clear_all() + print(json.dumps({'deleted': counts}, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/server/conversation_cache.py b/server/conversation_cache.py index c4bff1b..8e89e26 100644 --- a/server/conversation_cache.py +++ b/server/conversation_cache.py @@ -6,28 +6,57 @@ import hashlib import json import os +import re import sqlite3 import uuid from contextlib import contextmanager from pathlib import Path from settings import ROOT -from .config import canonical_model +from .config import canonical_model, model_provider from .schemas import ChatMessage from .openai_format import _text_of from .recovery_status import RECOVERY_STOP_TEXT +from .tool_notice import notice_of, provider_tool_error_followup +from .toolcall_logging import (enabled as toolcall_logging_enabled, + new_chat as new_toolcall_log, + record as record_toolcall_log) + +# OpenCode (and similar agents) inject ephemeral wrappers into +# user text around tool loops. Those wrappers are not part of the delivered +# assistant turn and must not break one-use history lookup. +_SYSTEM_REMINDER_RE = re.compile( + r"\s*The user sent the following message:\s*(.*?)\s*" + r"Please address this message and continue with your tasks\.\s*" + r"", + re.DOTALL, +) +_SYSTEM_REMINDER_ANY_RE = re.compile(r".*?\s*", re.DOTALL) class ConversationStateError(RuntimeError): pass +def _stable_text(content): + text = content or '' + if not isinstance(text, str): + return text + # Unwrap nested queued-follow-up envelopes back to the original user text. + previous = None + while previous != text: + previous = text + text = _SYSTEM_REMINDER_RE.sub(r'\1', text).strip() + # Drop other ephemeral reminder blocks (plan/build, image notices, …). + return _SYSTEM_REMINDER_ANY_RE.sub('', text).strip() + + def normalized(message): value = message.model_dump(exclude_none=True) if hasattr(message, 'model_dump') else dict(message) content = value.get('content') if isinstance(content, list) and all(p.get('type') == 'text' for p in content): content = _text_of(content) - value['content'] = content or '' + value['content'] = _stable_text(content) if not value.get('tool_calls'): value.pop('tool_calls', None) for call in value.get('tool_calls', []): @@ -40,14 +69,25 @@ def normalized(message): return value -def digest(req, messages): - # Owner-prefixed aliases (deepseek/deepseek-expert) must share history with - # the bare public id used when the link was stored. +def _message_role(message): + return message.get('role') if isinstance(message, dict) else getattr(message, 'role', None) + + +def _digest_payload(req, messages): + """Return the exact canonical data whose serialization digest() hashes.""" model = canonical_model(req.model) or req.model - payload = [model, req.user, req.thinking, req.search, req.tools, - [normalized(m) for m in messages]] - return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False, - separators=(',', ':')).encode()).hexdigest() + stable = [normalized(m) for m in messages if _message_role(m) != 'system'] + return [model, req.user, req.thinking, req.search, req.tools, stable] + + +def digest_payload_json(req, messages): + """Exact canonical JSON input to digest(), shared with safe diagnostic logging.""" + return json.dumps(_digest_payload(req, messages), sort_keys=True, ensure_ascii=False, + separators=(',', ':')) + + +def digest(req, messages): + return hashlib.sha256(digest_payload_json(req, messages).encode()).hexdigest() class ConversationCache: @@ -59,6 +99,18 @@ def __init__(self, path, account_file=None): self.path = Path(path) self.account_file = Path(account_file) if account_file is not None else None + def clear_used_links_on_startup(self): + raw = os.getenv('CONVERSATION_CLEAR_USED_ON_STARTUP', '0').strip() + if raw not in ('0', '1'): + raise ValueError('CONVERSATION_CLEAR_USED_ON_STARTUP must be 0 or 1') + if raw == '0': + return 0 + with self.connection() as db: + # This deliberately permits repeating an initial prompt in a new chat, + # but identical histories can then be matched to the wrong upstream chat. + cursor = db.execute('DELETE FROM links WHERE used=1') + return cursor.rowcount + def account_identity(self): scope = 'isolated' if self.account_file is not None: @@ -82,6 +134,8 @@ def connection(self): os.chmod(self.path, 0o600) db = sqlite3.connect(str(self.path), timeout=5) db.execute('CREATE TABLE IF NOT EXISTS links (key TEXT PRIMARY KEY, cid TEXT NOT NULL, used INTEGER NOT NULL, steps INTEGER NOT NULL)') + db.execute('CREATE TABLE IF NOT EXISTS trace_links (cid TEXT PRIMARY KEY, trace_path TEXT NOT NULL)') + db.execute('BEGIN IMMEDIATE') if 'reservation' not in [row[1] for row in db.execute('PRAGMA table_info(links)')]: db.execute('ALTER TABLE links ADD COLUMN reservation TEXT') @@ -93,7 +147,8 @@ def connection(self): if db is not None: db.close() - def prepare(self, req, *, scope=None, claim=None, legacy_scopes=(), allow_local_rollover=False): + def prepare(self, req, *, scope=None, claim=None, compact_results=False, + legacy_scopes=(), allow_local_rollover=False, trace_path=None): # Explicit protocol clients already own their conversation identity. if req.conversation_id: return req, 0 @@ -127,31 +182,90 @@ def prepare(self, req, *, scope=None, claim=None, legacy_scopes=(), allow_local_ raise ConversationStateError('Ambiguous continuation; no new chat was created.') calls = {call.get('id'): call for call in (req.messages[end - 1].tool_calls or [])} result_ids = [m.tool_call_id for m in tail if m.role == 'tool'] + # OpenAI-compatible clients may omit tool_call_id when there was only + # one delivered tool call. Bind that single result to the only call. + # TODO: We intentionally canonicalize the single-call case where + # tool_call_id is omitted. Histories that differ only by that ID are + # treated as the same continuation because real provider chats are not + # expected to diverge only on a synthetic tool identifier. + if result_ids == [None] and len(calls) == 1: + bound_id = next(iter(calls)) + result_ids = [bound_id] + normalized_messages = [ + m.model_copy(update={'tool_call_id': bound_id}) + if m.role == 'tool' and m.tool_call_id is None else m + for m in req.messages + ] + req = req.model_copy(update={'messages': normalized_messages}) + tail = req.messages[end:] if len(set(result_ids)) != len(result_ids) or set(result_ids) != set(calls): raise ConversationStateError('Tool results do not match the delivered calls.') key = self.key(req, req.messages[:end], scope) + search = { + 'key': key, + 'scope': scope if scope is not None else self.account_identity(), + 'history': self._history_metadata(req, req.messages[:end]), + } + if trace_path is None and toolcall_logging_enabled(): + trace_path = new_toolcall_log(model_provider(req.model)) with self.connection() as db: + record_toolcall_log(trace_path, 'search_chat', search) row = db.execute('SELECT cid, used, steps FROM links WHERE key=?', (key,)).fetchone() # A namespace upgrade must not orphan histories stored before it. # The legacy link is claimed exactly like a current one. for legacy in () if row else legacy_scopes: legacy_key = self.key(req, req.messages[:end], legacy) + legacy_search = { + 'key': legacy_key, + 'scope': legacy, + 'history': self._history_metadata(req, req.messages[:end]), + } + record_toolcall_log(trace_path, 'search_chat', legacy_search) row = db.execute('SELECT cid, used, steps FROM links WHERE key=?', (legacy_key,)).fetchone() if row: key = legacy_key break - if not row or row[1]: - raise ConversationStateError('History is unknown or already consumed; automatic replay and new-chat fallback are disabled.') + if not row: + raise ConversationStateError( + 'History is unknown; automatic replay and new-chat fallback are disabled.') + if row[1]: + raise ConversationStateError( + 'This message history matches a previously used chat, so the request was matched to that older chat and cannot be replayed. To start a new chat, change the initial message so its history is unique. Automatic replay and new-chat fallback are disabled.') if row[2] >= self.max_replies and not (allow_local_rollover and claim is not None): raise ConversationStateError(f'Local conversation limit reached ({self.max_replies} accepted replies); continuation stopped. This per-conversation cap is separate from hourly/daily budgets and is not reset by /bridge-budget-reset.') # Claim before dispatch. An uncertain response must never replay. reservation = uuid.uuid4().hex + consume_event = { + 'key': key, + 'conversation_id': row[0], + 'previous_used': row[1], + 'used': 1, + 'previous_steps': row[2], + 'reservation': reservation, + 'reason': 'continuation_claimed_before_provider_dispatch_to_prevent_replay_after_uncertain_response', + 'history': self._history_metadata(req, req.messages[:end]), + } + # Keep the claim update parameterized so the database mutation and its + # audit event stay adjacent under the same immediate transaction. + record_toolcall_log(trace_path, 'consume_chat', consume_event) db.execute('UPDATE links SET used=1, reservation=? WHERE key=?', (reservation, key)) if claim is not None: claim.update(key=key, reservation=reservation) tail = [m.model_copy(update={'name': calls[m.tool_call_id]['function']['name']}) if m.role == 'tool' else m for m in tail] - if result_ids: + # A bridge notice about a rejected tool reply is a delivered assistant turn + # the provider chat never saw; hand it to the model with the user's next + # message as a failed Tool result so the provider loop matches a tool error. + notice = notice_of(_text_of(req.messages[end - 1].content)) + if notice and not calls and tail and tail[0].role == 'user': + tail = [tail[0].model_copy(update={ + 'content': provider_tool_error_followup( + notice, _text_of(tail[0].content))}), *tail[1:]] + # Multiple results of the same tool name need a call-ID mapping block; + # compact rendering still preserves each result's own tool_call_id. + names = [calls[cid]['function']['name'] for cid in result_ids] + unambiguous = compact_results and len(set(names)) == len(names) + if result_ids and not unambiguous: # Bridge-generated IDs were not present in the upstream answer. # Supply the exact delivered calls to bind even out-of-order results. tail = [ChatMessage(role='assistant', @@ -182,13 +296,72 @@ def claim_rollover(self, claim, previous_cid, archive): db.execute('INSERT INTO rollovers VALUES (?, ?, ?)', (claim['key'], previous_cid, str(archive))) + @staticmethod + def _history_metadata(req, messages): + """Return exactly the canonical JSON hashed by digest().""" + return digest_payload_json(req, messages) + def remember(self, original, reply, content, calls, steps, *, scope=None): if original.conversation_id or not reply.conversation_id: return assistant = ChatMessage(role='assistant', content=content, tool_calls=calls) - key = self.key(original, [*original.messages, assistant], scope) + messages = [*original.messages, assistant] + key = self.key(original, messages, scope) + entry = { + 'key': key, + 'conversation_id': reply.conversation_id, + 'used': 0, + 'steps': steps + 1, + 'scope': scope if scope is not None else self.account_identity(), + 'history': self._history_metadata(original, messages), + } + trace = new_toolcall_log(model_provider(original.model)) + record_toolcall_log(trace, 'add_chat', entry) with self.connection() as db: - db.execute('INSERT INTO links (key,cid,used,steps) VALUES (?, ?, 0, ?) ON CONFLICT(key) DO UPDATE SET used=1, reservation=NULL', (key, reply.conversation_id, steps + 1)) + existing = db.execute('SELECT cid, used, steps FROM links WHERE key=?', (key,)).fetchone() + if existing: + # A pre-existing key is updated and consumed to avoid reusing the old link. + record_toolcall_log(trace, 'consume_chat', { + 'key': key, + 'conversation_id': existing[0], + 'previous_used': existing[1], + 'used': 1, + 'previous_steps': existing[2], + 'reservation': None, + 'reason': 'remember_insert_conflict_updates_existing_link', + 'history': self._history_metadata(original, messages), + }) + db.execute( + 'UPDATE links SET cid=?, used=1, steps=?, reservation=NULL WHERE key=?', + (reply.conversation_id, steps + 1, key)) + else: + db.execute('INSERT INTO links (key,cid,used,steps) VALUES (?, ?, 0, ?)', + (key, reply.conversation_id, steps + 1)) + + def trace_path(self, conversation_id): + if not conversation_id: + return None + try: + with self.connection() as db: + row = db.execute('SELECT trace_path FROM trace_links WHERE cid=?', + (conversation_id,)).fetchone() + return row[0] if row else None + except ConversationStateError: + # Existing conversations should not fail just because tracing state is unavailable. + return None + + def remember_trace(self, conversation_id, trace_path): + if not conversation_id or not trace_path: + return + try: + with self.connection() as db: + db.execute('INSERT INTO trace_links (cid,trace_path) VALUES (?, ?) ' + 'ON CONFLICT(cid) DO UPDATE SET trace_path=excluded.trace_path', + (conversation_id, str(trace_path))) + except ConversationStateError: + # Tracing is observational; preserve the provider response if unavailable. + return + conversation_cache = ConversationCache(ROOT / 'session' / 'deepseek-conversations.sqlite3', diff --git a/server/openai_format.py b/server/openai_format.py index e35fdeb..885e881 100644 --- a/server/openai_format.py +++ b/server/openai_format.py @@ -23,8 +23,8 @@ _ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant"} # Only the complete, explicitly requested protocol may produce actions. -_TOOL_BLOCK_RE = re.compile(r"\A```tool_calls[ \t]*\r?\n(.*?)\r?\n```[ \t]*\Z", re.DOTALL) -_TOOL_START_RE = re.compile(r"\A```tool_calls[ \t]*\r?\n") +_TOOL_BLOCK_RE = re.compile(r"\A```tool_calls(?:[ \t]+[^\r\n]*)?\r?\n(.*?)\r?\n```[ \t]*\Z", re.DOTALL) +_TOOL_START_RE = re.compile(r"\A```tool_calls(?:[ \t]+[^\r\n]*)?\r?\n") _CLOSING_TOOL_FENCE_RE = re.compile(r"(?m)^```[ \t]*\r?$") _TOOL_MARKUP_RE = re.compile( r"(?m)^[ \t]*```(?:tool_calls|calls)[ \t]*\r?$" @@ -39,9 +39,52 @@ class ToolCallError(RuntimeError): """The model emitted an invalid or disallowed explicit tool reply.""" - def __init__(self, message, *, repair_hint=None): + def __init__(self, message, *, repair_hint=None, guidance=None, kind=None, tool=None): super().__init__(message) self.repair_hint = repair_hint + # Model-facing, value-free explanation (tool name + expected argument + # shape); never contains argument values or reply text. + self.guidance = guidance + # Browser providers: "syntax" (broken JSON), "no_name" (no tool name), + # "unknown_tool", "schema"/"format" (known tool, wrong structure), + # "repeat" (call repeats the one just executed). `tool` is the sanitized + # tool name when one is known. + self.kind = kind + self.tool = tool + # The provider Reply that failed validation (set by the API layer). + self.reply = None + + +def find_tool_blocks(text: str): + """Top-level fenced ```tool_calls blocks as (start, end, body, closed) spans. + + Fences are tracked line by line, so a tool_calls fence inside another code + fence (a quoted example) is not a block. A final block left unclosed at the + end of the text is returned with closed=False. + """ + blocks = [] + pos = 0 + label = start = body_start = None + for line in (text or "").splitlines(keepends=True): + bare = line.rstrip("\r\n") + if label is None: + opened = re.fullmatch(r"[ \t]*```([A-Za-z0-9_-]*)(?:[ \t]+[^\r\n]*)?", bare) + if opened: + label, start, body_start = opened.group(1), pos, pos + len(line) + elif re.fullmatch(r"[ \t]*```[ \t]*", bare): + if label == "tool_calls": + blocks.append((start, pos + len(line), text[body_start:pos], True)) + label = None + pos += len(line) + if label == "tool_calls": + blocks.append((start, len(text), text[body_start:], False)) + return blocks + + +def tool_catalog(tools: List[dict]) -> str: + """Every advertised tool with its full argument shape (`?` = optional); never shortened.""" + return "\n".join(f"- {spec['name']}: {_schema_sketch(spec['parameters'])}" + for spec in _tool_specs(tools)) def has_tool_call_markup(text: str, allowed_names=None) -> bool: @@ -287,17 +330,54 @@ def _tool_instructions(tools: List[dict], tool_choice=None) -> str: return instructions -def _tool_reminder(tools: List[dict], tool_choice=None) -> str: - """Short follow-up block when tool-instructions mode is light.""" +def _schema_sketch(schema, depth: int = 0) -> str: + """Compact argument shape: `{a: string, b?: [{x: number}]}`; `?` = optional. + + Only structure (names, types, enums); descriptions and values are omitted. + """ + if not isinstance(schema, dict) or depth > 7: + return "any" + if isinstance(schema.get("enum"), list) and schema["enum"]: + return "|".join(json.dumps(v, ensure_ascii=False) for v in schema["enum"][:8]) + for key in ("anyOf", "oneOf"): + if isinstance(schema.get(key), list) and schema[key]: + return "|".join(_schema_sketch(s, depth + 1) for s in schema[key][:4]) + kind = schema.get("type") + if isinstance(kind, list): + kind = "|".join(str(k) for k in kind) + props = schema.get("properties") + if (kind in (None, "object")) and isinstance(props, dict) and props: + required = schema.get("required") if isinstance(schema.get("required"), list) else [] + parts = [f"{name}{'' if name in required else '?'}: {_schema_sketch(sub, depth + 1)}" + for name, sub in list(props.items())[:24]] + return "{" + ", ".join(parts) + "}" + if kind == "array": + return "[" + _schema_sketch(schema.get("items"), depth + 1) + "]" + return str(kind) if kind else "any" + + +def _tool_reminder(tools: List[dict], tool_choice=None, shapes: bool = False) -> str: + """Short follow-up block: `light` (names only) or `medium` (+ argument shapes).""" specs = _tool_specs(tools) if not specs: return "" names = ", ".join(spec["name"] for spec in specs) + shape_block = "" + if shapes: + lines = [] + for spec in specs: + sketch = _schema_sketch(spec["parameters"]) + if len(sketch) > 700: + sketch = sketch[:700] + "…" + lines.append(f"- {spec['name']}: {sketch}") + shape_block = ("Argument shapes (`?` = optional; use exactly these field names and " + "nesting):\n" + "\n".join(lines) + "\n") example = json.dumps([{"name": "", "arguments": {"": ""}}]) text = ( "Tools remain available from earlier in this chat: " f"{names}.\n" + + shape_block + "Next reply: either a single fenced tool_calls block as the WHOLE reply " "(exact tool names; arguments matching those schemas):\n\n" "```tool_calls\n" @@ -309,7 +389,7 @@ def _tool_reminder(tools: List[dict], tool_choice=None) -> str: return text + _tool_choice_suffix(tool_choice) -def _render_message(m: ChatMessage, call_names: dict) -> str: +def _render_message(m: ChatMessage, call_names: dict, compact_results: bool = False) -> str: content = _text_of(m.content) if m.role == "assistant" and m.tool_calls: calls = [] @@ -331,6 +411,10 @@ def _render_message(m: ChatMessage, call_names: dict) -> str: return f"Assistant: {content}" if m.role == "tool": label = m.name or call_names.get(m.tool_call_id, "tool") + if compact_results: + # Preserve the request's tool_call_id so the provider can bind this result. + call_id = f' id="{m.tool_call_id}"' if m.tool_call_id else "" + return f"Tool result ({label}{call_id}): {content}" return f"Tool ({label}, call_id={m.tool_call_id or 'unknown'}): {content}" label = _ROLE_LABELS.get(m.role, m.role.capitalize()) return f"{label}: {content}" @@ -339,7 +423,7 @@ def _render_message(m: ChatMessage, call_names: dict) -> str: def messages_to_prompt( messages: List[ChatMessage], tools: List[dict] = None, tool_choice=None, *, tools_continuation: bool = False, provider: str = None, - tool_instructions: str = None, + tool_instructions: str = None, tool_results_compact: bool = False, ) -> str: """Flatten a chat history into a single prompt DeepSeek can answer. @@ -352,12 +436,17 @@ def messages_to_prompt( `tools_continuation=True` means this turn resumes an existing provider chat. Mode comes from `tool_instructions`, else TOOL_INSTRUCTIONS_BROWSER/HTTP via `provider` (defaults to full when neither is set). full → complete schemas - every turn; light → short reminder on continuations; none → no tools block + every turn; medium → short reminder plus each tool's argument shape on + continuations; light → short reminder (names only); none → no tools block on continuations (first turn still gets the full block). + + `tool_results_compact` (browser providers): a continuation that carries tool + results gets no tools reminder, and a single unambiguous result is sent as + just `Tool result (name): ` (no call_id, no echoed call). """ from .config import tool_instructions_for_provider - if tool_instructions in ("full", "light", "none"): + if tool_instructions in ("full", "medium", "light", "none"): mode = tool_instructions elif provider: mode = tool_instructions_for_provider(provider) @@ -372,12 +461,19 @@ def messages_to_prompt( else: convo.append(m) + compact = bool(tool_results_compact and tools_continuation + and any(m.role == "tool" for m in convo)) + # Without a re-listed call block (see ConversationCache.prepare) the + # results are unambiguous and are sent bare. + bare_results = compact and not any(m.role == "assistant" and m.tool_calls for m in convo) tool_instr = "" if tools: - if mode == "none" and tools_continuation: + if compact: tool_instr = "" - elif mode == "light" and tools_continuation: - tool_instr = _tool_reminder(tools, tool_choice) + elif mode == "none" and tools_continuation: + tool_instr = "" + elif mode in ("light", "medium") and tools_continuation: + tool_instr = _tool_reminder(tools, tool_choice, shapes=mode == "medium") else: tool_instr = _tool_instructions(tools, tool_choice) @@ -394,10 +490,13 @@ def messages_to_prompt( fn = call.get("function") or {} if isinstance(fn, dict) and call.get("id"): call_names[call["id"]] = fn.get("name", "tool") - lines.extend(_render_message(m, call_names) for m in convo) + lines.extend(_render_message(m, call_names, bare_results) for m in convo) if tool_instr: - lines.append(tool_instr) - lines.append("Assistant:") + # Mark the injected tool block as a developer instruction at the point + # it is actually included; do not change empty/other instruction paths. + lines.append("Developer: " + tool_instr) + if not bare_results: + lines.append("Assistant:") return "\n\n".join(lines) @@ -645,7 +744,8 @@ def parse_tool_calls( try: data = _tool_json(payload.rstrip() + "]") except (ValueError, TypeError): - raise _json_tool_error("The tool_calls block contains invalid JSON", exc) from exc + raise _json_tool_error("The tool_calls block contains invalid JSON", exc, + kind="syntax") from exc elif (allow_closing_artifacts and exc.msg == "Extra data" and _CLOSING_ARTIFACTS_RE.fullmatch(payload[exc.pos:].strip())): @@ -655,25 +755,42 @@ def parse_tool_calls( # or tolerate any other suffix. Markdown framing is independent. data = _tool_json(payload[:exc.pos]) else: - raise _json_tool_error("The tool_calls block contains invalid JSON", exc) from exc + raise _json_tool_error("The tool_calls block contains invalid JSON", exc, + kind="syntax") from exc except ValueError as exc: - raise ToolCallError("The tool_calls block contains invalid JSON") from exc + raise ToolCallError("The tool_calls block contains invalid JSON", kind="syntax") from exc if not isinstance(data, list) or not data: - raise ToolCallError("tool_calls must be a non-empty JSON array") + single = data.get("name") if isinstance(data, dict) else None + named = isinstance(single, str) and bool(single) + raise ToolCallError( + "tool_calls must be a non-empty JSON array", + kind="format" if named else "no_name", + tool=_safe_tool_name(single), + guidance=(f"The call to `{_safe_tool_name(single)}` was not executed: tool_calls must be " + "a JSON array of {\"name\": ..., \"arguments\": {...}} entries.") if named else None) names = set(allowed_names) if allowed_names is not None else None calls = [] for item in data: if not isinstance(item, dict): - raise ToolCallError("Each tool call must be an object") + raise ToolCallError("Each tool call must be an object", kind="no_name") fn = item.get("function") if isinstance(item.get("function"), dict) else item if fn is not item: if set(item) - {"id", "type", "function"} or set(fn) - {"name", "arguments"}: - raise ToolCallError("Tool call contains ambiguous envelope fields") + raise ToolCallError("Tool call contains ambiguous envelope fields", kind="format", + tool=_safe_tool_name(fn.get("name"))) elif "arguments" in item and set(item) - {"id", "type", "name", "arguments"}: - raise ToolCallError("Tool call contains ambiguous envelope fields") + raise ToolCallError("Tool call contains ambiguous envelope fields", kind="format", + tool=_safe_tool_name(item.get("name"))) name = fn.get("name") - if not isinstance(name, str) or not name or (names is not None and name not in names): - raise ToolCallError("The model called an unadvertised tool") + if not isinstance(name, str) or not name: + raise ToolCallError("The model called an unadvertised tool", kind="no_name") + if names is not None and name not in names: + shown = _safe_tool_name(name) + available = ", ".join(sorted(str(n) for n in names)[:40]) + raise ToolCallError( + "The model called an unadvertised tool", kind="unknown_tool", tool=shown, + guidance=f"`{shown}` is not an available tool." + + (f" Available tools: {available}." if available else "")) args = fn.get("arguments") if "arguments" not in fn and fn is item: args = _flat_tool_arguments(item, flat_argument_tools) @@ -681,21 +798,27 @@ def parse_tool_calls( try: args = _tool_json(args) except json.JSONDecodeError as exc: - raise _json_tool_error("Tool arguments contain invalid JSON", exc) from exc + raise _json_tool_error("Tool arguments contain invalid JSON", exc, + kind="format", tool=name, + guidance=_shape_guidance(name, flat_argument_tools)) from exc except ValueError as exc: - raise ToolCallError("Tool arguments contain invalid JSON") from exc + raise ToolCallError("Tool arguments contain invalid JSON", kind="format", + tool=name, guidance=_shape_guidance(name, flat_argument_tools)) from exc if not isinstance(args, dict): - raise ToolCallError("Tool arguments must be a JSON object") + raise ToolCallError("Tool arguments must be a JSON object", kind="format", tool=name, + guidance=_shape_guidance(name, flat_argument_tools)) try: arguments = json.dumps(args, ensure_ascii=False, allow_nan=False) except ValueError as exc: - raise ToolCallError("Tool arguments contain non-finite numbers") from exc + raise ToolCallError("Tool arguments contain non-finite numbers", kind="format", + tool=name) from exc if flat_argument_tools is not None: from .tool_schema import validate_arguments try: validate_arguments(name, args, flat_argument_tools) except ValueError as exc: - raise ToolCallError(str(exc)) from None + raise ToolCallError(str(exc), kind="schema", tool=name, + guidance=_shape_guidance(name, flat_argument_tools)) from None calls.append({ "id": f"call_{uuid.uuid4().hex[:24]}", "type": "function", @@ -704,7 +827,24 @@ def parse_tool_calls( return "", calls -def _json_tool_error(message: str, exc: json.JSONDecodeError) -> ToolCallError: +def _safe_tool_name(name) -> str: + return re.sub(r"[^\w.\-]", "", name)[:60] if isinstance(name, str) else "" + + +def _shape_guidance(name: str, tools) -> Optional[str]: + """Value-free reminder of one tool's argument shape (`?` = optional).""" + spec = next((s for s in _tool_specs(tools or []) if s["name"] == name), None) + shape = _schema_sketch(spec["parameters"]) if spec else "" + if not shape: + return None + if len(shape) > 1500: + shape = shape[:1500] + "…" + return (f"The call to `{name}` was not executed: its arguments do not match the advertised " + f"schema. Expected arguments for `{name}` (`?` = optional): {shape}") + + +def _json_tool_error(message: str, exc: json.JSONDecodeError, *, kind=None, tool=None, + guidance=None) -> ToolCallError: # Only fixed categories and numeric positions leave the parser. Decoder # messages/documents can contain command text or secrets; never echo them. if exc.msg.startswith("Invalid control character"): @@ -721,14 +861,20 @@ def _json_tool_error(message: str, exc: json.JSONDecodeError) -> ToolCallError: repair_hint=(f"JSON validation failed: {reason}, line {exc.lineno}, column {exc.colno} " "within the JSON payload (not the Markdown fence). " "Regenerate the whole valid block, not a patch or suffix. " - "Check commas, quoted keys, and escaping of quotes and backslashes inside strings.") + "Check commas, quoted keys, and escaping of quotes and backslashes inside strings."), + kind=kind, tool=tool, guidance=guidance, ) def looks_truncated(text: str, allowed_names: Optional[Iterable[str]] = None) -> bool: - """Continue only an explicitly started tool block missing its closing fence.""" - stripped = (text or "").strip() - return bool(_TOOL_START_RE.match(stripped)) and not _CLOSING_TOOL_FENCE_RE.search(stripped) + """Continue when a top-level tool_calls fence was opened and not closed. + + The block may sit anywhere in the reply (browser mid-message prose + fence). + Nested/quoted fences are ignored the same way as ``find_tool_blocks``. + ``allowed_names`` is kept for call-site compatibility; truncation is + fence-shaped only. + """ + return any(not closed for _start, _end, _body, closed in find_tool_blocks(text or "")) # --- OpenAI response shapes ----------------------------------------------- @@ -749,11 +895,13 @@ def _est_tokens(text: str) -> int: def completion_response(model: str, content: str, prompt: str, conversation_id: str = None, tool_calls: List[dict] = None, - finish_reason: str = "stop") -> dict: + finish_reason: str = "stop", bridge_notice: dict = None) -> dict: """A full (non-streaming) OpenAI chat.completion object. `conversation_id` is an extra top-level field (outside OpenAI's schema) you - send back to resume the conversation. + send back to resume the conversation. When `bridge_notice` is set (invalid + tool under TOOLCALL_INVALID_MODE=text), the message also carries OpenAI + `refusal` and a top-level `bridge_notice` for agent-side step failure. """ pt, ct = _est_tokens(prompt), _est_tokens(content or "") message: dict = {"role": "assistant", "content": content or ""} @@ -762,7 +910,10 @@ def completion_response(model: str, content: str, prompt: str, message["tool_calls"] = tool_calls message["content"] = content or None finish = "tool_calls" - return { + if bridge_notice: + # OpenAI refusal: agents that honor it treat the turn as a failed step. + message["refusal"] = bridge_notice.get("message") or bridge_notice.get("code") + obj = { "id": _id(), "object": "chat.completion", "created": _now(), @@ -781,11 +932,14 @@ def completion_response(model: str, content: str, prompt: str, "total_tokens": pt + ct, }, } + if bridge_notice: + obj["bridge_notice"] = bridge_notice + return obj def sse_frames(model: str, content: str, tool_calls: List[dict] = None, conversation_id: str = None, cid: str = None, created: int = None, - finish_reason: str = "stop") -> Iterable[str]: + finish_reason: str = "stop", bridge_notice: dict = None) -> Iterable[str]: """Yield OpenAI SSE frames for an already-computed reply. Used when the caller has a full result (e.g. it ran a non-streaming request @@ -815,16 +969,25 @@ def frame(delta: dict, finish=None, extra: dict = None) -> str: "type": "function", "function": call["function"], }]}) - yield frame({}, finish="tool_calls", extra={"conversation_id": conversation_id}) + extra = {"conversation_id": conversation_id} + if bridge_notice: + extra["bridge_notice"] = bridge_notice + yield frame({}, finish="tool_calls", extra=extra) else: if content: yield frame({"content": content}) - yield frame({}, finish=finish_reason, extra={"conversation_id": conversation_id}) + if bridge_notice: + yield frame({"refusal": bridge_notice.get("message") or bridge_notice.get("code")}) + extra = {"conversation_id": conversation_id} + if bridge_notice: + extra["bridge_notice"] = bridge_notice + yield frame({}, finish=finish_reason, extra=extra) yield "data: [DONE]\n\n" def stream_chunks(model: str, stream: Iterable[str], tool_supported: bool = False, - iterator: Iterable[str] = None) -> Iterable[str]: + iterator: Iterable[str] = None, *, include_role: bool = True, + include_final: bool = True) -> Iterable[str]: """Yield OpenAI SSE lines (`data: {...}\\n\\n`) for a streamed completion. `stream` is the client's stream object; after it's consumed we read its @@ -860,12 +1023,15 @@ def frame(delta: dict, finish=None, extra: dict = None) -> str: finish_reason=getattr(stream, "finish_reason", "stop")) return - # First frame announces the assistant role. - yield frame({"role": "assistant", "content": ""}) + # First frame announces the assistant role unless already sent by a caller + # that streams provider chunks incrementally. + if include_role: + yield frame({"role": "assistant", "content": ""}) for d in it: if d: yield frame({"content": d}) - conversation_id = getattr(stream, "conversation_id", None) - yield frame({}, finish=getattr(stream, "finish_reason", "stop"), - extra={"conversation_id": conversation_id}) - yield "data: [DONE]\n\n" + if include_final: + conversation_id = getattr(stream, "conversation_id", None) + yield frame({}, finish=getattr(stream, "finish_reason", "stop"), + extra={"conversation_id": conversation_id}) + yield "data: [DONE]\n\n" diff --git a/server/schemas.py b/server/schemas.py index 5a2e513..f7589db 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -4,7 +4,7 @@ from typing import List, Optional, Union -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, PrivateAttr, field_validator from .config import DEFAULT_MODEL @@ -48,6 +48,8 @@ def validate_historical_calls(cls, value): class ChatCompletionRequest(BaseModel): + _toolcall_log_path: object = PrivateAttr(default=None) + model: str = DEFAULT_MODEL messages: List[ChatMessage] stream: bool = False diff --git a/server/tool_notice.py b/server/tool_notice.py new file mode 100644 index 0000000..5c9d4c8 --- /dev/null +++ b/server/tool_notice.py @@ -0,0 +1,95 @@ +"""Local notice for a rejected tool reply, delivered as a normal assistant message. + +The agent shows the model's raw reply followed by a one-line notice and keeps the +chat open (a raised error would leave the provider link consumed). The next user +message carries the notice to the model. A notice names at most the offending +tool; it never lists tools or argument shapes and never echoes argument values. + +API responses also carry a structured `bridge_notice` object and `message.refusal` +so the agent can treat the turn as a failed tool step without relying on prose alone. +""" + +import re +from typing import Optional + +from .openai_format import ToolCallError + +NOTICE_PREFIX = "Bridge notice: " +STATUS_CODE = "BRIDGE_TOOL_NOT_EXECUTED" +_SEPARATOR = "\n\n" + NOTICE_PREFIX + + +def notice_of(text) -> Optional[str]: + """The bridge notice at the end of an assistant message, or None.""" + if not isinstance(text, str): + return None + if text.startswith(NOTICE_PREFIX): + return text + at = text.rfind(_SEPARATOR) + return text[at + 2:] if at >= 0 else None + + +def is_notice(text) -> bool: + return notice_of(text) is not None + + +def _detail(exc: ToolCallError) -> str: + tool = exc.tool + if exc.kind == "unknown_tool": + return f"`{tool}` is not an available tool." if tool else "it named no available tool." + if exc.kind in ("schema", "format") and tool: + return f"the arguments of `{tool}` do not match its schema." + if exc.kind == "repeat": + return "it repeats the call that was just executed, with the same arguments." + return f"the reply was rejected: {exc}" + + +def tool_failure_notice(exc: ToolCallError) -> str: + return ( + f"{NOTICE_PREFIX}{STATUS_CODE}: the tool call in the message above was NOT executed: " + f"{_detail(exc)} " + "(Local message: no tool ran. Send a follow-up, e.g. \"retry\", if the action " + "is still needed.)" + ) + + +def tool_failure_message(raw_text: str, exc: ToolCallError) -> str: + """The model's raw reply followed by the notice, as one assistant message.""" + notice = tool_failure_notice(exc) + raw = (raw_text or "").rstrip() + return f"{raw}\n\n{notice}" if raw else notice + + +def bridge_notice_payload(exc: ToolCallError) -> dict: + """Structured failure for the agent (OpenAI-compatible extras on the completion). + + `as_tool_error` mirrors a tool-role error payload so agents can fold the turn into + the usual tool-failure path without the bridge inventing executable tool_calls + (those would be run locally or desync one-use history). + """ + detail = _detail(exc) + tool = exc.tool or None + reason = f"NOT executed: {detail}" + return { + "executed": False, + "code": STATUS_CODE, + "kind": exc.kind, + "tool": tool, + "message": tool_failure_notice(exc), + "as_tool_error": { + "name": tool or "tool", + "content": reason, + }, + } + + +def provider_tool_error_followup(notice: str, user_text: str) -> str: + """Format the next provider prompt like a failed tool result + user follow-up.""" + if STATUS_CODE in (notice or ""): + named = re.search(r"`([^`]+)`", notice or "") + name = named.group(1) if named else "tool" + payload = f"Tool result ({name}): {notice}" + else: + payload = notice + user = (user_text or "").strip() + return f"{payload}\n\n{user}" if user else payload diff --git a/server/tool_protocol_new.py b/server/tool_protocol_new.py new file mode 100644 index 0000000..25d6353 --- /dev/null +++ b/server/tool_protocol_new.py @@ -0,0 +1,147 @@ +"""TOOLCALL_LEGACY=0 reply contract: demote + multi-block validate for every provider. + +Used only when TOOLCALL_LEGACY=0. Main-era validate lives in tool_protocol.py. +""" + +import re +from typing import Optional + +from .openai_format import ( + ToolCallError, enforce_tool_choice, find_tool_blocks, parse_tool_calls, +) + +# Call-level "name" before "arguments" (not argument fields named "name"). +# Comma optional so a missing-comma syntax error still exposes a known tool name. +_CALL_NAME_BEFORE_ARGS_RE = re.compile( + r'"name"\s*:\s*"((?:\\.|[^"\\])*)"\s*,?\s*"arguments"') +_FUNCTION_NAME_RE = re.compile( + r'"function"\s*:\s*\{\s*"name"\s*:\s*"((?:\\.|[^"\\])*)"') +_OPEN_TOOL_CALLS_FENCE_RE = re.compile(r'^([ \t]*```)tool_calls\b', re.MULTILINE) + + +def _call_names_in_payload(body: str) -> list[str]: + """Best-effort tool names from a fence body (valid or broken JSON).""" + names = [] + for match in _CALL_NAME_BEFORE_ARGS_RE.finditer(body or ""): + names.append(match.group(1)) + for match in _FUNCTION_NAME_RE.finditer(body or ""): + names.append(match.group(1)) + return names + + +def _prose_around_tool_blocks(text: str, blocks) -> str: + pieces, at = [], 0 + for start, end, _body, _closed in blocks: + pieces.append(text[at:start]) + at = end + pieces.append(text[at:]) + return "".join(pieces) + + +def _rewrite_tool_calls_fences_to_text(text: str, blocks) -> str: + """Relabel top-level ```tool_calls fences as ```text (advice, not a call).""" + result = text + for start, end, _body, _closed in reversed(blocks): + segment = result[start:end] + rewritten, n = _OPEN_TOOL_CALLS_FENCE_RE.subn(r'\1text', segment, count=1) + if n: + result = result[:start] + rewritten + result[end:] + return result + + +def demote_mistagged_tool_reply(text, tools) -> Optional[str]: + """If a reply is advice mistagged as tool_calls, return rewritten text. + + Demote (```tool_calls → ```text) when every top-level tool fence is either: + - JSON (valid or broken) with no call-level ``name``, or + - an unknown ``name`` plus accompanying prose outside the fences. + + Returns None when the reply should follow the normal tool validate path + (real calls, schema/format errors, unknown tool with no prose, …). + """ + text = text or "" + blocks = find_tool_blocks(text) + if not blocks: + return None + names = {t.get("function", t).get("name") for t in tools or []} + names.discard(None) + has_prose = bool(_prose_around_tool_blocks(text, blocks).strip()) + saw_demote = False + for _start, _end, body, _closed in blocks: + wrapped = "```tool_calls\n" + body.strip("\r\n") + "\n```" + try: + _, block_calls = parse_tool_calls( + wrapped, allowed_names=names, + allow_closing_artifacts=True, allow_missing_array_closer=True, + flat_argument_tools=tools) + except ToolCallError as exc: + if exc.kind == "no_name": + saw_demote = True + continue + if exc.kind == "syntax": + extracted = _call_names_in_payload(body) + if not extracted: + saw_demote = True + continue + if has_prose and all(n not in names for n in extracted): + saw_demote = True + continue + return None + if exc.kind == "unknown_tool" and has_prose: + saw_demote = True + continue + return None + if block_calls: + return None + if not saw_demote: + return None + return _rewrite_tool_calls_fences_to_text(text, blocks) + + +def validate_reply(text, tools, tool_choice=None, *, finish_reason="stop", provider="deepseek"): + """LEGACY=0 validate: same multi-block contract for every provider. + + Only a top-level fenced ```tool_calls block can be a call; arrays without that + fence, other fences and prose are plain text, wherever they stand. Advice + mistagged as tool_calls is demoted in the API layer before this runs. Every + remaining block must parse: syntax, no_name, unknown tool, or bad arguments + fail the whole message. ``tool_choice=none`` never turns a block into an + error. When every block is valid, all calls run in order and the text around + them, with the call blocks cut out, is returned as content. + + ``provider`` is kept for call-site compatibility; the path does not fork on it. + """ + del provider + text = text or "" + names = [t.get("function", t).get("name") for t in tools or []] + finished = finish_reason == "stop" + blocks = find_tool_blocks(text) + if blocks and not finished and tool_choice != "none": + raise ToolCallError("The provider did not finish the tool reply normally; no calls were returned") + calls, spans = [], [] + for start, end, body, _closed in blocks: + wrapped = "```tool_calls\n" + body.strip("\r\n") + "\n```" + try: + _, block_calls = parse_tool_calls( + wrapped, allowed_names=names, + allow_closing_artifacts=True, allow_missing_array_closer=True, + flat_argument_tools=tools) + except ToolCallError: + if tool_choice == "none": + continue + raise + calls.extend(block_calls) + spans.append((start, end)) + if not calls: + if not text.strip(): + raise ToolCallError("Provider returned no answer text or tool calls; no actions were returned") + enforce_tool_choice(tool_choice, None) + return text, None + enforce_tool_choice(tool_choice, calls) + pieces, at = [], 0 + for start, end in spans: + pieces.append(text[at:start]) + at = end + pieces.append(text[at:]) + content = "\n\n".join(piece.strip("\r\n") for piece in pieces if piece.strip()) + return content, calls diff --git a/server/toolcall_logging.py b/server/toolcall_logging.py new file mode 100644 index 0000000..552ef00 --- /dev/null +++ b/server/toolcall_logging.py @@ -0,0 +1,178 @@ +"""Opt-in per-conversation trace logging at the agent/provider boundary. + +Trace files contain raw prompts and replies (including tool inputs/results), so +this feature is disabled by default and its output directory is git-ignored. +""" + +from __future__ import annotations + +import json +import os +import re +import threading +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LOG_DIR = ROOT / "logs" +SEPARATOR = { + "from_agent": "------------ from agent---------------", + "to_provider": "------------ to provider---------------", + "from_provider": "------------ from provider---------------", + "to_agent": "------------ to agent---------------", + "add_chat": "-----------------------------add chat -------------------", + "search_chat": "-----------------------Search chat--------------------------", + "consume_chat": "-----------------------Consume chat--------------------------", +} +_lock = threading.RLock() + + +def enabled() -> bool: + """Whether tracing is enabled; environment is read dynamically.""" + return os.getenv("TOOLCALL_LOG", "0").strip() == "1" + + +def _safe_provider(provider: str) -> str: + value = re.sub(r"[^A-Za-z0-9._-]+", "_", str(provider or "unknown")).strip("._-") + return (value or "unknown")[:64] + + +def _new_path(provider: str) -> Path | None: + if not enabled(): + return None + timestamp = datetime.now().strftime("%m%d%H%M%S") + base = LOG_DIR / f"{timestamp}-{_safe_provider(provider)}.log" + with _lock: + try: + LOG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + LOG_DIR.chmod(0o700) + except OSError: + pass + except OSError: + return None + suffix = 1 + candidate = base + while True: + try: + fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.close(fd) + try: + candidate.chmod(0o600) + except OSError: + pass + return candidate + except FileExistsError: + candidate = base.with_name(f"{base.stem}-{suffix}{base.suffix}") + suffix += 1 + except OSError: + return None + + +def new_chat(provider: str) -> Path | None: + """Create a trace file for a new provider conversation.""" + return _new_path(provider) + + +def _pretty_json(text: str) -> str: + try: + value = json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + return json.dumps(value, ensure_ascii=False, indent=2) + + +def _pretty_sse(text: str) -> str: + """Pretty-print JSON after SSE data prefixes without changing JSON strings.""" + output = [] + cursor = 0 + pattern = re.compile(r"(?m)^([ \t]*data:[ \t]*)") + while True: + match = pattern.search(text, cursor) + if not match: + output.append(text[cursor:]) + break + output.append(text[cursor:match.start()]) + prefix_start = match.start(1) + value_start = match.end(1) + output.append(match.group(1)) + if text.startswith("[DONE]", value_start): + end = value_start + len("[DONE]") + output.append("[DONE]") + cursor = end + continue + + decoder = json.JSONDecoder() + try: + value, end = decoder.raw_decode(text, value_start) + except json.JSONDecodeError: + # Not a JSON SSE data value: retain it verbatim. + line_end = text.find("\n", value_start) + if line_end < 0: + output.append(text[value_start:]) + break + output.append(text[value_start:line_end]) + cursor = line_end + continue + formatted = json.dumps(value, ensure_ascii=False, indent=2) + formatted_lines = formatted.splitlines() + output.append(formatted_lines[0]) + if len(formatted_lines) > 1: + output.append("\n" + "\n".join(formatted_lines[1:])) + cursor = end + return "".join(output) + + +def _format_payload(text: str) -> str: + formatted = _pretty_json(text) + if formatted != text: + return formatted + if "data:" in text: + return _pretty_sse(text) + return text + + +def _payload_text(content) -> str: + """Normalize boundary payloads into text without Python repr for JSON objects.""" + if isinstance(content, bytes): + return content.decode("utf-8", errors="replace") + if isinstance(content, str): + return content + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False) + + # Starlette responses (JSONResponse, etc.) carry their serialized wire body. + body = getattr(content, "body", None) + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + if isinstance(body, str): + return body + return str(content) + + +def record(path: Path | str | None, direction: str, content) -> None: + """Append one exact boundary payload, with JSON prettified for readability.""" + if not enabled(): + return + if direction not in SEPARATOR: + raise ValueError("Unknown tool-call trace direction") + if path is None: + path = new_chat("conversation-cache") + if path is None: + return + text = _payload_text(content) + text = _format_payload(text) + block = f"{SEPARATOR[direction]}\n{text}" + if not block.endswith("\n"): + block += "\n" + with _lock: + try: + with Path(path).open("a", encoding="utf-8", newline="") as stream: + stream.write(block) + except OSError: + return + + +def record_error(path: Path | str | None, direction: str, error: BaseException) -> None: + """Log an exception at the boundary where it is returned.""" + record(path, direction, f"{type(error).__name__}: {error}") diff --git a/tests/support.py b/tests/support.py index ad13573..9a7f78e 100644 --- a/tests/support.py +++ b/tests/support.py @@ -5,9 +5,15 @@ from providers.access import AccessGuard from providers import access -from server import api +from server import api, config from server.config import DEEPSEEK_MODEL_MAP, QWEN_MODEL_MAP +# CI leaves browser providers off; tests still need *-web → provider resolution +# so `_conversation_tool_chat` does not fall through to DeepSeek `get_client()`. +_BROWSER_WEB = ("grok", "mistral", "glm", "chatgpt", "claude") +_WEB_MODELS = {f"{p}-web": "default" for p in _BROWSER_WEB} +_WEB_OWNERS = {f"{p}-web": p for p in _BROWSER_WEB} + def isolated_access(case): """Legacy transport fixtures explicitly enable models; never touch real state.""" @@ -17,7 +23,12 @@ def isolated_access(case): cache_patch.start() case.addCleanup(cache_patch.stop) isolated = AccessGuard(path=None, interval=0) - for patcher in (patch.object(api, "guard", isolated), patch.object(access, "guard", isolated), - patch.dict(api.MODEL_MAP, {**DEEPSEEK_MODEL_MAP, **QWEN_MODEL_MAP}, clear=True)): + for patcher in ( + patch.object(api, "guard", isolated), + patch.object(access, "guard", isolated), + patch.dict(api.MODEL_MAP, + {**DEEPSEEK_MODEL_MAP, **QWEN_MODEL_MAP, **_WEB_MODELS}, + clear=True), + patch.dict(config.OPTIONAL_MODEL_PROVIDERS, _WEB_OWNERS)): patcher.start() case.addCleanup(patcher.stop) diff --git a/tests/test_access.py b/tests/test_access.py index fa5ceda..0318576 100644 --- a/tests/test_access.py +++ b/tests/test_access.py @@ -51,6 +51,29 @@ def test_pause_survives_restart_and_resume_in_separate_process_view(self): restarted.resume("deepseek") guard.check("deepseek") + def test_access_pause_opt_out_skips_latch_and_all_shared_blocking(self): + guard = AccessGuard(self.path, interval=0) + # Simulate state latched by a previous server run with the default on. + guard.reject("deepseek", ProviderRejected("account_restricted")) + with patch.dict(os.environ, {'PROVIDER_ACCESS_PAUSE': '0'}): + # The opt-out means callers need not run `resume` to clear either a + # stored pause or an unresolved pending owner. + guard.check("deepseek") + attempt = guard.begin("deepseek") + attempt.dispatch() + attempt.complete() + error = guard.reject("deepseek", ProviderRejected("quota_exceeded")) + self.assertEqual(error.code, "quota_exceeded") + self.assertEqual(guard.status(), {}) + # Default on: a newly latched pause still blocks until resume. + with self.assertLogs("providers.access", level="WARNING"): + guard.resume("deepseek") + guard.reject("deepseek", ProviderRejected("quota_exceeded")) + with self.assertRaises(ProviderRejected): + AccessGuard(self.path, interval=0).check("deepseek") + with patch.dict(os.environ, {'PROVIDER_ACCESS_PAUSE': '0'}): + AccessGuard(self.path, interval=0).check("deepseek") + def test_new_pause_logs_sanitized_http_shape_not_payload_text(self): request = httpx.Request("POST", "https://chat.qwen.ai/api/v2/chat/completions") response = httpx.Response( @@ -159,6 +182,17 @@ def test_malformed_state_and_failed_write_stop_requests(self): with self.assertRaises(ProviderRejected): guard.check("qwen") + def test_removed_provider_records_are_ignored(self): + self.path.write_text(json.dumps({ + 'qwen': {'next_at': 10}, + 'removed-provider': {'pause': 'upstream_failure', 'pending': 'f' * 32, + 'dispatched': True}, + })) + guard = AccessGuard(self.path, interval=0) + guard.check('qwen') + with guard._state() as state: + self.assertEqual(set(state), {'qwen'}) + def test_pacing_can_be_cancelled_without_starting_another_attempt(self): guard = AccessGuard(path=None, interval=60) guard.wait("qwen") diff --git a/tests/test_api.py b/tests/test_api.py index 9b81f1a..94844de 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -31,6 +31,26 @@ def auth_error(): return httpx.HTTPStatusError("token expired",request=request,response=response) +class ProviderPauseMessageTests(unittest.TestCase): + def test_disabled_pause_message_does_not_suggest_resume_command(self): + model = 'mistral-web' + with patch.object(api, 'model_provider', return_value='mistral'): + exc = ProviderRejected('upstream_failure', source='upstream') + with patch.dict('os.environ', {'PROVIDER_ACCESS_PAUSE': '0'}): + message = api._provider_pause_message(exc, model) + self.assertIn('Provider blocking is disabled', message) + self.assertNotIn('providers.access resume mistral', message) + + def test_enabled_pause_message_explains_resume_command(self): + model = 'mistral-web' + provider = 'mistral' + with patch.object(api, 'model_provider', return_value=provider): + exc = ProviderRejected('upstream_failure', source='upstream') + with patch.dict('os.environ', {'PROVIDER_ACCESS_PAUSE': '1'}): + message = api._provider_pause_message(exc, model) + self.assertIn(f'providers.access resume {provider}', message) + + class TextStream: conversation_id = "fake:2" finish_reason = "stop" @@ -420,7 +440,8 @@ async def test_flat_tool_inputs_require_normal_stop_and_respect_tool_choice(self with self.subTest(finish=finish, policy=policy, stream=stream): fake = Mock() fake.chat.return_value = Reply(answer, 'fixture:2', finish) - with patch.object(api, 'get_client', return_value=fake), \ + with patch.object(api, '_FORMAT_REPAIR', False), \ + patch.object(api, 'get_client', return_value=fake), \ patch.object(api, '_continue_reply') as continuation: response = await self.post(tools=FLAT_TOOLS, tool_choice=policy, stream=stream) if finish == 'stop' and policy == 'auto': @@ -440,7 +461,8 @@ async def test_malformed_json_batch_has_safe_diagnostics_and_no_replay(self): with self.subTest(stream=stream): fake = Mock() fake.chat.return_value = Reply(reply, "fake:2") - with patch.object(api, "get_client", return_value=fake), \ + with patch.object(api, "_FORMAT_REPAIR", False), \ + patch.object(api, "get_client", return_value=fake), \ patch.object(api, "_continue_reply") as continuation, \ patch.object(api, "save_tool_failure") as diagnostic, \ patch.object(api, "_toolcall_debug", return_value=False), \ @@ -649,7 +671,8 @@ async def test_combined_envelope_errors_require_stop_in_both_response_modes(self async def test_unfinished_json_after_normal_stop_never_requests_continuation(self): fake = Mock() fake.chat.return_value = Reply('```tool_calls\n[{"name":"write","arguments":{', "fake:2") - with patch.object(api, "get_client", return_value=fake), \ + with patch.object(api, "_FORMAT_REPAIR", False), \ + patch.object(api, "get_client", return_value=fake), \ patch.object(api, "_MAX_CONTINUATIONS", 3), \ patch.object(api, "_continue_reply") as continuation: response = await self.post(tools=TOOLS) @@ -675,7 +698,9 @@ async def test_output_limit_never_executes_even_a_complete_tool_array(self): async def test_exhausted_continuation_returns_error_instead_of_partial_write(self): fake = Mock() fake.chat.return_value = Reply('```tool_calls\n[{"name":"write","arguments":{}},{',"fake:2") - with patch.object(api,"get_client",return_value=fake), patch.object(api,"_MAX_CONTINUATIONS",0): + with patch.object(api, "_FORMAT_REPAIR", False), \ + patch.object(api, "get_client", return_value=fake), \ + patch.object(api, "_MAX_CONTINUATIONS", 0): response = await self.post(tools=TOOLS) self.assertEqual(response.status_code,400) self.assertEqual(fake.chat.call_count,1) @@ -960,15 +985,19 @@ def refresh(): generator = api._plain_stream(fake, "audit", req, "default") waiters = [] try: - await generator.__anext__() - self.assertIn("first", await generator.__anext__()) + frames = [] + try: + while True: + frames.append(await asyncio.wait_for(generator.__anext__(), .5)) + except StopAsyncIteration: + pass + + self.assertGreater(len(frames), 0) + with patch.object(api, "get_client", side_effect=refresh): waiters = [asyncio.create_task(api.chat_completions(req)) for _ in range(4)] await asyncio.sleep(.03) - frame = await asyncio.wait_for(generator.__anext__(), .5) - self.assertIn("second", frame) release.set() - await generator.aclose() await asyncio.gather(*waiters) finally: release.set() diff --git a/tests/test_calls_fence.py b/tests/test_calls_fence.py index 221c0c3..20c1cb0 100644 --- a/tests/test_calls_fence.py +++ b/tests/test_calls_fence.py @@ -2,15 +2,19 @@ import unittest from server.openai_format import ToolCallError -from server.tool_protocol import validate_reply +from server.tool_protocol import validate_reply as validate_legacy +from server.tool_protocol_new import validate_reply as validate_rework from tests.test_tool_protocol import tool class CallsFenceTests(unittest.TestCase): + """```calls alias is main-era DeepSeek only (TOOLCALL_LEGACY=1 / tool_protocol).""" + def test_complete_alias_preserves_arguments_after_prose(self): args = {'file_path': '/fixture', 'content': 'quoted "data"\n```calls'} - raw = 'Let me inspect it.\n\n```calls\n' + json.dumps([{'name': 'read', 'arguments': args}]) + '\n```' - _, calls = validate_reply(raw, [tool('read')]) + raw = ('Let me inspect it.\n\n```calls\n' + + json.dumps([{'name': 'read', 'arguments': args}]) + '\n```') + _, calls = validate_legacy(raw, [tool('read')], provider='deepseek') self.assertEqual(json.loads(calls[0]['function']['arguments']), args) def test_invalid_batch_and_schema_fail_closed(self): @@ -21,7 +25,8 @@ def test_invalid_batch_and_schema_fail_closed(self): '[{"name":"read","arguments":{"file_path":"a","content":"b","content":"c"}}]', '[{"name":"read","arguments":{"file_path":"a","content":"b"}}'): with self.subTest(payload=payload), self.assertRaises(ToolCallError): - validate_reply('```calls\n' + payload + '\n```', [tool('read')]) + validate_legacy( + '```calls\n' + payload + '\n```', [tool('read')], provider='deepseek') def test_quoted_nested_multiple_truncated_and_trailing_blocks_never_execute(self): raw = '```calls\n[{"name":"read","arguments":{"file_path":"a","content":"b"}}]\n```' @@ -30,10 +35,18 @@ def test_quoted_nested_multiple_truncated_and_trailing_blocks_never_execute(self raw[:-4], raw + '\nTool: invented result'] for text in variants: try: - _, calls = validate_reply(text, [tool('read')]) + _, calls = validate_legacy(text, [tool('read')], provider='deepseek') except ToolCallError: continue self.assertIsNone(calls, text) - for kwargs in ({'finish_reason': 'length'}, {'provider': 'qwen'}, {'tool_choice': 'none'}): + for kwargs in ({'finish_reason': 'length'}, {'tool_choice': 'none'}): with self.assertRaises(ToolCallError): - validate_reply(raw, [tool('read')], **kwargs) + validate_legacy(raw, [tool('read')], provider='deepseek', **kwargs) + + def test_rework_path_never_executes_calls_alias(self): + raw = '```calls\n[{"name":"read","arguments":{"file_path":"a","content":"b"}}]\n```' + for provider in ('deepseek', 'qwen', 'claude'): + with self.subTest(provider=provider): + content, calls = validate_rework(raw, [tool('read')], provider=provider) + self.assertIsNone(calls) + self.assertEqual(content, raw) diff --git a/tests/test_conversation_admin.py b/tests/test_conversation_admin.py new file mode 100644 index 0000000..3d76819 --- /dev/null +++ b/tests/test_conversation_admin.py @@ -0,0 +1,34 @@ +import tempfile +import unittest +from pathlib import Path + +from server.conversation_admin import clear_all +from server.conversation_cache import ConversationCache + + +class ConversationAdminTests(unittest.TestCase): + def test_clear_removes_links_trace_associations_and_rollovers(self): + with tempfile.TemporaryDirectory() as directory: + cache = ConversationCache(Path(directory) / 'conversations.sqlite3') + with cache.connection() as db: + db.execute("INSERT INTO links (key,cid,used,steps,reservation) VALUES ('k','cid',1,2,NULL)") + db.execute("INSERT INTO trace_links (cid,trace_path) VALUES ('cid','trace.log')") + db.execute('CREATE TABLE rollovers (key TEXT PRIMARY KEY, previous_cid TEXT NOT NULL, archive TEXT NOT NULL)') + db.execute("INSERT INTO rollovers VALUES ('k','cid','archive')") + + counts = clear_all(cache) + + self.assertEqual(counts, {'links': 1, 'trace_links': 1, 'rollovers': 1}) + with cache.connection() as db: + self.assertEqual(db.execute('SELECT COUNT(*) FROM links').fetchone()[0], 0) + self.assertEqual(db.execute('SELECT COUNT(*) FROM trace_links').fetchone()[0], 0) + self.assertEqual(db.execute('SELECT COUNT(*) FROM rollovers').fetchone()[0], 0) + + def test_clear_handles_database_without_rollovers_table(self): + with tempfile.TemporaryDirectory() as directory: + cache = ConversationCache(Path(directory) / 'conversations.sqlite3') + self.assertEqual(clear_all(cache), {'links': 0, 'trace_links': 0, 'rollovers': 0}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_conversation_cache.py b/tests/test_conversation_cache.py index b8af766..8ad8b88 100644 --- a/tests/test_conversation_cache.py +++ b/tests/test_conversation_cache.py @@ -17,6 +17,77 @@ class ConversationTests(unittest.TestCase): + def test_add_and_search_events_log_fields_without_message_text(self): + from server import toolcall_logging + from server.conversation_cache import digest_payload_json + request = ChatCompletionRequest( + model='deepseek-chat', tools=FLAT_TOOLS, + messages=[ChatMessage(role='user', content='PRIVATE PROMPT CONTENT')]) + with patch.dict('os.environ', {'TOOLCALL_LOG': '1'}), \ + patch('server.conversation_cache.new_toolcall_log', return_value=Path('trace.log')) as new_chat, \ + patch('server.conversation_cache.record_toolcall_log') as record: + self.cache.remember(request, self.reply, 'assistant PRIVATE TEXT', None, 0, + scope='isolated-test') + self.assertEqual(new_chat.call_count, 1) + added = record.call_args.args[2] + self.assertEqual(added['conversation_id'], 'owned:2') + self.assertEqual(added['used'], 0) + self.assertEqual(added['steps'], 1) + self.assertIn('PRIVATE PROMPT CONTENT', added['history']) + self.assertIn('assistant PRIVATE TEXT', added['history']) + self.assertEqual(added['history'], digest_payload_json( + request, [*request.messages, ChatMessage(role='assistant', content='assistant PRIVATE TEXT')])) + self.assertEqual(record.call_args.args[1], 'add_chat') + request = request.model_copy(update={'messages': [ + ChatMessage(role='user', content='PRIVATE PROMPT CONTENT'), + ChatMessage(role='assistant', content='assistant PRIVATE TEXT'), + ChatMessage(role='user', content='next message'), + ]}) + with patch.dict('os.environ', {'TOOLCALL_LOG': '1'}), \ + patch('server.conversation_cache.new_toolcall_log', return_value=Path('trace.log')), \ + patch('server.conversation_cache.record_toolcall_log') as record: + self.cache.prepare(request, scope='isolated-test') + calls = [call.args[1] for call in record.call_args_list] + self.assertIn('search_chat', calls) + search_call = next(call for call in record.call_args_list if call.args[1] == 'search_chat') + searched = search_call.args[2] + self.assertEqual(searched['key'], self.cache.key(request, request.messages[:2], 'isolated-test')) + self.assertEqual(searched['history'], digest_payload_json( + request, request.messages[:2])) + self.assertNotIn('fixture result', repr(searched)) + + def test_clear_used_links_on_startup_is_opt_in_and_preserves_unused(self): + self.cache.remember(self.req, self.reply, '', self.calls, 0, scope='isolated-test') + req = self.req.model_copy(update={'messages': self.history()}) + self.cache.prepare(req, scope='isolated-test') + with self.cache.connection() as db: + db.execute('INSERT INTO links (key,cid,used,steps) VALUES (?, ?, ?, ?)', + ('unused-key', 'unused:1', 0, 1)) + with patch.dict('os.environ', {'CONVERSATION_CLEAR_USED_ON_STARTUP': '0'}): + self.assertEqual(self.cache.clear_used_links_on_startup(), 0) + with patch.dict('os.environ', {'CONVERSATION_CLEAR_USED_ON_STARTUP': '1'}): + self.assertEqual(self.cache.clear_used_links_on_startup(), 1) + with self.cache.connection() as db: + self.assertIsNone(db.execute('SELECT 1 FROM links WHERE used=1').fetchone()) + self.assertIsNotNone(db.execute('SELECT 1 FROM links WHERE key=?', + ('unused-key',)).fetchone()) + + def test_clear_used_links_on_startup_rejects_invalid_value(self): + with patch.dict('os.environ', {'CONVERSATION_CLEAR_USED_ON_STARTUP': 'yes'}): + with self.assertRaises(ValueError): + self.cache.clear_used_links_on_startup() + + def test_trace_path_follows_provider_conversation_across_requests(self): + trace = self.cache.path.parent / 'trace.log' + self.cache.remember_trace(self.reply.conversation_id, trace) + self.assertEqual(self.cache.trace_path(self.reply.conversation_id), str(trace)) + self.assertIsNone(self.cache.trace_path('unknown:2')) + + def test_trace_path_survives_conversation_cache_restart(self): + trace = self.cache.path.parent / 'trace.log' + self.cache.remember_trace(self.reply.conversation_id, trace) + restarted = ConversationCache(self.cache.path) + self.assertEqual(restarted.trace_path(self.reply.conversation_id), str(trace)) def test_local_stop_preserves_original_results_and_single_use_claim(self): from server.recovery_status import RECOVERY_STOP_TEXT self.cache.remember(self.req, self.reply, '', self.calls, 0) @@ -176,9 +247,38 @@ def test_invalid_conversation_cap_fails_startup(self): def test_ambiguous_identical_histories_are_not_merged(self): self.cache.remember(self.req, self.reply, '', self.calls, 0) self.cache.remember(self.req, Reply('', 'another:2'), '', self.calls, 0) - with self.assertRaises(ConversationStateError): + with self.assertRaisesRegex( + ConversationStateError, + 'matches a previously used chat'): self.cache.prepare(self.req.model_copy(update={'messages': self.history()})) + def test_opencode_system_drift_and_reminder_wrappers_still_continue(self): + """OpenCode mutates system prompts and may wrap user text between turns.""" + from server.conversation_cache import digest + first = self.req.model_copy(update={'messages': [ + ChatMessage(role='system', content='env day-1\nAGENTS.md v1'), + ChatMessage(role='user', content='Explore owned fixture'), + ]}) + self.cache.remember(first, self.reply, '', self.calls, 0) + wrapped = ( + "\nThe user sent the following message:\n" + "Explore owned fixture\n\n" + "Please address this message and continue with your tasks.\n" + "" + ) + follow = first.model_copy(update={'messages': [ + ChatMessage(role='system', content='env day-2\nAGENTS.md v2'), + ChatMessage(role='user', content=wrapped), + ChatMessage(role='assistant', tool_calls=self.calls), + ChatMessage(role='tool', tool_call_id='call_owned', content='fixture result'), + ]}) + self.assertEqual(digest(first, first.messages + [ + ChatMessage(role='assistant', tool_calls=self.calls)]), + digest(follow, follow.messages[:-1])) + continued, steps = self.cache.prepare(follow) + self.assertEqual(continued.conversation_id, 'owned:2') + self.assertEqual(steps, 1) + def test_transport_creates_one_chat_for_two_agent_steps(self): from deepseek.client import DeepSeekClient from tests.test_stream import snapshot diff --git a/tests/test_tool_history.py b/tests/test_tool_history.py index 8cc1b75..74beffa 100644 --- a/tests/test_tool_history.py +++ b/tests/test_tool_history.py @@ -55,7 +55,7 @@ async def exchange(request): self.assertIn(value, sent) self.assertNotIn('Inspect two files', sent) duplicate = asyncio.run(exchange(continuation)) - self.assertIn('already consumed', duplicate) + self.assertIn('previously used chat', duplicate) self.assertEqual(fake.chat.call_count, 2) with api.conversation_cache.connection() as db: self.assertEqual(db.execute('SELECT steps, used FROM links ORDER BY steps').fetchall(), diff --git a/tests/test_tool_legacy.py b/tests/test_tool_legacy.py new file mode 100644 index 0000000..2b0eeeb --- /dev/null +++ b/tests/test_tool_legacy.py @@ -0,0 +1,97 @@ +"""TOOLCALL_LEGACY=1 (api/tool_protocol) vs 0 (api_new/tool_protocol_new).""" +import unittest +from unittest.mock import Mock, patch + +from chat_protocol import Reply +from server import api, api_new +from server.openai_format import ToolCallError +from server.schemas import ChatCompletionRequest, ChatMessage +from server.api import looks_truncated +from server.tool_protocol import validate_reply +from tests.support import isolated_access +from tests.test_tools import block + +WRITE = {"type": "function", "function": { + "name": "write", "parameters": { + "type": "object", "required": ["file_path", "content"], + "properties": {"file_path": {"type": "string"}, "content": {"type": "string"}}, + "additionalProperties": False}}} + + +class ToolLegacyUnitTests(unittest.TestCase): + def test_looks_truncated_requires_reply_to_start_with_fence(self): + cut = '```tool_calls\n[{"name":"write","arguments":{"content":"x' + mid = 'Intro.\n```tool_calls\n[{"name":"write","arguments":{"content":"x' + self.assertTrue(looks_truncated(cut)) + self.assertFalse(looks_truncated(mid)) + + def test_validate_is_single_block_even_for_browser_provider_name(self): + prose = 'First.\n\n' + block([{"name": "write", "arguments": { + "file_path": "/x", "content": "y"}}]) + '\n\nDone.' + with self.assertRaises(ToolCallError): + validate_reply(prose, [WRITE], provider='claude') + + +class ToolLegacyApiTests(unittest.TestCase): + def setUp(self): + isolated_access(self) + self.req = ChatCompletionRequest( + model='claude-web', tools=[WRITE], + messages=[ChatMessage(role='user', content='write')]) + + def chat(self, replies, *, legacy, invalid_mode='text'): + with patch.object(api, '_LEGACY', legacy), \ + patch.object(api, '_FORMAT_REPAIR', False), \ + patch.object(api_new, '_INVALID_MODE', invalid_mode), \ + patch.object(api, '_chat_with_retry', side_effect=replies) as chat: + return api._conversation_tool_chat('p', self.req, None), chat + + def test_legacy_ignores_invalid_mode_text(self): + bad = block([{"name": "write", "arguments": {"file_path": "/x"}}]) + with self.assertRaises(ToolCallError): + self.chat([Reply(bad, 'claude:1')], legacy=True, invalid_mode='text') + + def test_new_path_can_return_bridge_notice(self): + bad = block([{"name": "write", "arguments": {"file_path": "/x"}}]) + (reply, content, calls), chat = self.chat( + [Reply(bad, 'claude:1')], legacy=False, invalid_mode='text') + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertIsNotNone(reply.bridge_notice) + self.assertIn('BRIDGE_TOOL_NOT_EXECUTED', content) + + def test_legacy_mid_message_tool_fence_errors_without_demote(self): + advice = ('How to prompt:\n```tool_calls\n' + '[{"arguments": {"file_path": "/x"}}]\n```\nDone.') + with self.assertRaises(ToolCallError) as ctx: + self.chat([Reply(advice, 'claude:1')], legacy=True) + self.assertIn('Unsupported tool-call markup', str(ctx.exception)) + + def test_new_path_demotes_advice(self): + advice = ('How to prompt:\n```tool_calls\n' + '[{"arguments": {"file_path": "/x"}}]\n```\nDone.') + (_, content, calls), chat = self.chat([Reply(advice, 'claude:1')], legacy=False) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertIn('```text', content) + self.assertNotIn('```tool_calls', content) + + def test_new_path_demotes_on_deepseek_too(self): + advice = ('How to prompt:\n```tool_calls\n' + '[{"arguments": {"file_path": "/x"}}]\n```\nDone.') + req = self.req.model_copy(update={'model': 'deepseek-chat'}) + fake = Mock() + with patch.object(api, '_LEGACY', False), \ + patch.object(api, '_FORMAT_REPAIR', False), \ + patch.object(api, 'get_client', return_value=fake), \ + patch.object(api, '_chat_with_retry', + side_effect=[Reply(advice, 'ds:1')]) as chat: + _, content, calls = api._conversation_tool_chat('p', req, None) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertIn('```text', content) + self.assertNotIn('```tool_calls', content) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tool_notice.py b/tests/test_tool_notice.py new file mode 100644 index 0000000..0c9cff8 --- /dev/null +++ b/tests/test_tool_notice.py @@ -0,0 +1,330 @@ +"""Browser providers: an attempt at a tool call is validated like in any agent. + +Valid calls run and the text around them is kept. An unknown tool or arguments +that do not fit the schema stop the message: one pointed correction (repair on). +With TOOLCALL_INVALID_MODE=text the model's raw reply plus a one-line notice +reaches the agent as text (default mode is error). +""" +import json +import unittest +from unittest.mock import Mock, patch + +from chat_protocol import Reply +from server import api, api_new +from server.conversation_cache import ConversationStateError +from server.schemas import ChatCompletionRequest, ChatMessage +from server.tool_notice import ( + NOTICE_PREFIX, STATUS_CODE, bridge_notice_payload, provider_tool_error_followup, + tool_failure_notice, +) +from server.openai_format import ToolCallError, completion_response +from tests.support import isolated_access +from tests.test_tools import block + +QUESTION_TOOL = {"type": "function", "function": { + "name": "ask_user_question", "description": "PRIVATE-DOC", + "parameters": {"type": "object", "required": ["questions"], "properties": { + "questions": {"type": "array", "items": { + "type": "object", "required": ["question", "options"], "properties": { + "question": {"type": "string"}, + "options": {"type": "array", "items": { + "type": "object", "required": ["label"], + "properties": {"label": {"type": "string"}}}}}}}}}}} +OTHER_TOOL = {"type": "function", "function": { + "name": "read_file", "description": "OTHER-PRIVATE-DOC", + "parameters": {"type": "object", "required": ["path"], "properties": { + "path": {"type": "string"}, "limit": {"type": "integer"}}}}} +SHAPE = '{questions: [{question: string, options: [{label: string}]}]}' +SECRET = "SECRET-ARGUMENT-VALUE" +BAD = block([{"name": "ask_user_question", "arguments": {"question": SECRET, "options": ["a"]}}]) +GOOD = block([{"name": "ask_user_question", "arguments": {"questions": [ + {"question": "Drink?", "options": [{"label": "Tea"}]}]}}]) +FAKE = block([{"name": "made_up", "arguments": {"x": SECRET}}]) + + +class ToolNoticeTests(unittest.TestCase): + def setUp(self): + isolated_access(self) + # New tool-rework path (demote / INVALID_MODE / browser validate). + for target, name, value in ( + (api, '_LEGACY', False), + (api, '_FORMAT_REPAIR', False), + (api_new, '_INVALID_MODE', 'text')): + patcher = patch.object(target, name, value) + patcher.start() + self.addCleanup(patcher.stop) + self.req = ChatCompletionRequest( + model='claude-web', tools=[QUESTION_TOOL, OTHER_TOOL], + messages=[ChatMessage(role='user', content='ask me something')]) + + def chat(self, replies, req, prompt='p'): + with patch.object(api, '_chat_with_retry', side_effect=replies) as chat: + return api._conversation_tool_chat(prompt, req, None), chat + + def follow(self, content, user='retry please'): + history = [*self.req.messages, ChatMessage(role='assistant', content=content), + ChatMessage(role='user', content=user)] + return self.req.model_copy(update={'messages': history}) + + def test_schema_failure_is_raw_reply_plus_a_short_notice_and_the_chat_continues(self): + (reply, content, calls), chat = self.chat([Reply(BAD, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + # The model's own text first, then one notice line: no shapes, no tool list. + self.assertTrue(content.startswith(BAD)) + notice = content[len(BAD):].strip() + self.assertTrue(notice.startswith(NOTICE_PREFIX)) + self.assertIn(STATUS_CODE, notice) + self.assertIn('`ask_user_question`', notice) + self.assertIn('NOT executed', notice) + self.assertEqual(reply.bridge_notice['code'], STATUS_CODE) + self.assertEqual(reply.bridge_notice['executed'], False) + self.assertEqual(reply.bridge_notice['tool'], 'ask_user_question') + self.assertEqual(reply.bridge_notice['as_tool_error']['name'], 'ask_user_question') + for leak in (SHAPE, 'read_file', 'PRIVATE-DOC', 'Expected arguments'): + self.assertNotIn(leak, notice) + + follow = self.follow(content) + (second, _, second_calls), chat = self.chat([Reply(GOOD, 'claude:3')], follow, 'unused') + self.assertEqual(second_calls[0]['function']['name'], 'ask_user_question') + sent = chat.call_args.args[0] + self.assertEqual(chat.call_args.args[1].conversation_id, 'claude:1') + # Provider sees a tool-error style follow-up, then the user's retry. + self.assertIn('Tool result (ask_user_question):', sent) + self.assertIn('NOT executed', sent) + self.assertIn('retry please', sent) + self.assertNotIn(SECRET, sent) + self.assertNotIn('ask me something', sent) + # The link was consumed by that continuation: no replay. + with patch.object(api, '_chat_with_retry') as again, self.assertRaises(api.ToolCallError): + api._conversation_tool_chat('dup', follow, None) + again.assert_not_called() + + def test_bridge_notice_is_on_openai_completion_and_refusal(self): + exc = ToolCallError('bad', kind='schema', tool='ask_user_question') + notice = bridge_notice_payload(exc) + body = completion_response('claude-web', 'raw\n\n' + notice['message'], 'p', + 'claude:1', bridge_notice=notice) + self.assertEqual(body['bridge_notice']['code'], STATUS_CODE) + self.assertEqual(body['choices'][0]['message']['refusal'], notice['message']) + self.assertIn(STATUS_CODE, body['choices'][0]['message']['content']) + + def test_provider_followup_looks_like_tool_result_error(self): + notice = tool_failure_notice( + ToolCallError('bad', kind='unknown_tool', tool='made_up')) + text = provider_tool_error_followup(notice, 'retry please') + self.assertTrue(text.startswith('Tool result (made_up):')) + self.assertIn('retry please', text) + + def test_repair_on_wrong_arguments_get_one_pointed_correction(self): + with patch.object(api, '_FORMAT_REPAIR', True): + (_, content, calls), chat = self.chat( + [Reply(BAD, 'claude:1'), Reply(GOOD, 'claude:1')], self.req) + self.assertEqual(calls[0]['function']['name'], 'ask_user_question') + self.assertEqual(content, '') # the agent sees only the call + self.assertEqual(chat.call_count, 2) + prompt, correction_req = chat.call_args_list[1].args[:2] + self.assertEqual(correction_req.conversation_id, 'claude:1') + # This tool's shape only: no values, no other tool, no schema dump. + self.assertIn('Expected arguments for `ask_user_question`', prompt) + self.assertIn(SHAPE, prompt) + for leak in (SECRET, 'PRIVATE-DOC', 'read_file'): + self.assertNotIn(leak, prompt) + + def test_repair_on_unknown_tool_gets_the_full_tool_list(self): + with patch.object(api, '_FORMAT_REPAIR', True): + (_, _, calls), chat = self.chat([Reply(FAKE, 'claude:1'), Reply(GOOD, 'claude:1')], self.req) + self.assertEqual(calls[0]['function']['name'], 'ask_user_question') + prompt = chat.call_args_list[1].args[0] + self.assertIn('`made_up` is not an available tool', prompt) + self.assertIn('- ask_user_question: ' + SHAPE, prompt) + self.assertIn('- read_file: {path: string, limit?: integer}', prompt) + for leak in (SECRET, 'PRIVATE-DOC'): + self.assertNotIn(leak, prompt) + + def test_repair_on_second_failure_forwards_the_raw_reply_with_a_notice(self): + for first, second in ((BAD, BAD), (FAKE, FAKE), (FAKE, BAD)): + with self.subTest(first=first[:30], second=second[:30]), patch.object(api, '_FORMAT_REPAIR', True): + (_, content, calls), chat = self.chat( + [Reply(first, 'claude:1'), Reply(second, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 2) # exactly one correction, no loop + self.assertIsNone(calls) + self.assertTrue(content.startswith(second)) + self.assertIn(NOTICE_PREFIX, content) + self.assertNotIn(SHAPE, content) + self.assertNotIn('read_file', content) + + def test_repair_off_forwards_at_once_without_a_second_request(self): + for text, mention in ((BAD, 'the arguments of `ask_user_question`'), + (FAKE, '`made_up` is not an available tool')): + with self.subTest(text=text[:30]): + (_, content, calls), chat = self.chat([Reply(text, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertTrue(content.startswith(text)) + self.assertIn(mention, content) + + def test_abstract_blocks_are_demoted_text_with_no_correction(self): + text = ('Format:\n```tool_calls\n[ { ... }, { ... } ]\n```\n' + 'Placeholder:\n```tool_calls\n[{"name": "tool_name", "arguments": {...}}]\n```') + expected = text.replace('```tool_calls', '```text') + for repair in (False, True): + with self.subTest(repair=repair), patch.object(api, '_FORMAT_REPAIR', repair): + (_, content, calls), chat = self.chat([Reply(text, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertEqual(content, expected) + self.assertNotIn(NOTICE_PREFIX, content) + + def test_broken_json_with_known_tool_name_is_invalid_not_silent_text(self): + # Demote does not claim this is advice; validate must fail (repair / INVALID_MODE). + text = ('```tool_calls\n' + '[{"name": "ask_user_question" "arguments": {"questions": []}}]\n```') + with patch.object(api, '_FORMAT_REPAIR', False), patch.object(api_new, '_INVALID_MODE', 'text'): + (_, content, calls), chat = self.chat([Reply(text, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertTrue(content.startswith(text)) + self.assertIn(NOTICE_PREFIX, content) + self.assertIn('```tool_calls', content) + + def test_unknown_tool_with_prose_is_demoted_text_not_a_notice(self): + text = ('How to write a system prompt:\n' + FAKE + '\n' + 'Put the tools list after the role description.') + for repair in (False, True): + with self.subTest(repair=repair), patch.object(api, '_FORMAT_REPAIR', repair): + (_, content, calls), chat = self.chat([Reply(text, 'claude:1')], self.req) + self.assertEqual(chat.call_count, 1) + self.assertIsNone(calls) + self.assertEqual(content, text.replace('```tool_calls', '```text')) + self.assertNotIn(NOTICE_PREFIX, content) + + def test_valid_call_keeps_the_surrounding_text_and_the_history_hash(self): + answer = json.dumps({"answers": [{"id": "q1", "selected": ["Tea"]}]}) + + # OpenAI-compatible clients may omit tool_call_id when there was only + # one pending tool call. Histories that differ only by that ID are + # treated as equivalent after normalization in conversation_cache. + text = 'Let me ask.\n\n' + GOOD + '\n\nThat is the question.' + req = self.req.model_copy(update={'messages': [ + ChatMessage(role='user', content='ask me something')]} ) + (_, content, calls), _ = self.chat([Reply(text, 'claude:1')], req) + self.assertEqual(content, 'Let me ask.\n\nThat is the question.') + + history = [*req.messages, + ChatMessage(role='assistant', content=content, tool_calls=calls), + ChatMessage(role='tool', tool_call_id=calls[0]['id'], content=answer)] + follow = req.model_copy(update={'messages': [ + *history, + ChatMessage(role='user', content='continue') ]}) + (_, again, again_calls), chat = self.chat( + [Reply('You chose Tea.', 'claude:2')], follow, 'unused') + self.assertIsNone(again_calls) + self.assertEqual(again, 'You chose Tea.') + self.assertEqual( + chat.call_args.args[0], + f'Tool result (ask_user_question id="{calls[0]["id"]}"): {answer}\n\nUser: continue') + self.assertEqual(chat.call_args.args[1].conversation_id, 'claude:1') + + def test_results_of_one_tool_keep_the_identifier_mapping(self): + two = block([{"name": "read_file", "arguments": {"path": "a"}}, + {"name": "read_file", "arguments": {"path": "b"}}]) + (_, content, calls), _ = self.chat([Reply(two, 'claude:1')], self.req) + history = [*self.req.messages, + ChatMessage(role='assistant', content=content, tool_calls=calls), + *[ChatMessage(role='tool', tool_call_id=c['id'], content=f'r{i}') + for i, c in enumerate(calls)]] + follow = self.req.model_copy(update={'messages': history}) + _, chat = self.chat([Reply('done', 'claude:2')], follow, 'unused') + sent = chat.call_args.args[0] + self.assertIn('Previously requested calls', sent) + self.assertIn(f"call_id={calls[0]['id']}", sent) + + def test_repeating_the_executed_call_is_delivered_as_a_message_not_run(self): + (_, content, calls), _ = self.chat([Reply(GOOD, 'claude:1')], self.req) + history = [*self.req.messages, + ChatMessage(role='assistant', content=content, tool_calls=calls), + ChatMessage(role='tool', tool_call_id=calls[0]['id'], content='{"answers": []}')] + follow = self.req.model_copy(update={'messages': history}) + (reply, text, again), _ = self.chat([Reply(GOOD, 'claude:2')], follow, 'unused') + self.assertIsNone(again) + self.assertTrue(text.startswith(GOOD)) + self.assertIn('repeats the call that was just executed', text) + # The chat stays usable: the link of this message exists. + follow2 = self.req.model_copy(update={'messages': [ + *history, ChatMessage(role='assistant', content=text), + ChatMessage(role='user', content='go on')]}) + (_, ok, ok_calls), chat = self.chat([Reply('Fine.', 'claude:3')], follow2, 'unused') + self.assertEqual(ok, 'Fine.') + self.assertIn('repeats the call', chat.call_args.args[0]) + # A different call, or the same call after a user turn, runs normally. + fresh = self.req.model_copy(update={'messages': [ChatMessage(role='user', content='another')]}) + (_, content, calls), _ = self.chat([Reply(GOOD, 'claude:5')], fresh) + follow3 = fresh.model_copy(update={'messages': [ + *fresh.messages, ChatMessage(role='assistant', content=content, tool_calls=calls), + ChatMessage(role='tool', tool_call_id=calls[0]['id'], content='{"answers": []}')]}) + other = block([{"name": "read_file", "arguments": {"path": "a"}}]) + (_, _, other_calls), _ = self.chat([Reply(other, 'claude:6')], follow3, 'unused') + self.assertEqual(other_calls[0]['function']['name'], 'read_file') + + def test_every_browser_provider_behaves_the_same(self): + for model in ('claude-web', 'grok-web', 'mistral-web', 'chatgpt-web', 'glm-web'): + req = self.req.model_copy(update={'model': model}) + with self.subTest(model=model): + (_, content, calls), chat = self.chat([Reply(BAD, 'x:1')], req) + self.assertIsNone(calls) + self.assertTrue(content.startswith(BAD)) + self.assertIn(NOTICE_PREFIX, content) + self.assertEqual(chat.call_count, 1) + with self.subTest(model=model, repair=True), patch.object(api, '_FORMAT_REPAIR', True): + (_, _, calls), chat = self.chat( + [Reply(BAD, 'x:1'), Reply(GOOD, 'x:1')], req.model_copy(update={'messages': [ + ChatMessage(role='user', content='again')]})) + self.assertEqual(calls[0]['function']['name'], 'ask_user_question') + self.assertEqual(chat.call_count, 2) + + def test_default_error_mode_raises_for_browser_and_deepseek(self): + for model, reply_factory in ( + ('claude-web', lambda: self.chat([Reply(BAD, 'claude:1')], self.req)), + ('deepseek-chat', None), + ): + with self.subTest(model=model), patch.object(api_new, '_INVALID_MODE', 'error'): + if model == 'deepseek-chat': + fake = Mock() + fake.chat.return_value = Reply(BAD, 'owned:2') + req = self.req.model_copy(update={'model': 'deepseek-chat'}) + with patch.object(api, 'get_client', return_value=fake), \ + self.assertRaises(api.ToolCallError): + api._conversation_tool_chat('p', req, 'default') + else: + with self.assertRaises(api.ToolCallError): + reply_factory() + + def test_deepseek_text_mode_returns_notice(self): + fake = Mock() + fake.chat.return_value = Reply(BAD, 'owned:2') + req = self.req.model_copy(update={'model': 'deepseek-chat'}) + with patch.object(api, 'get_client', return_value=fake), \ + patch.object(api_new, '_INVALID_MODE', 'text'): + reply, content, calls = api._conversation_tool_chat('p', req, 'default') + self.assertIsNone(calls) + self.assertTrue(content.startswith(BAD)) + self.assertIn(NOTICE_PREFIX, content) + self.assertEqual(reply.conversation_id, 'owned:2') + + def test_cases_that_must_still_fail(self): + scenarios = [ + ('claude-web', Reply(BAD, 'claude:1'), 'required'), # forced tool choice + ('claude-web', Reply(BAD, None), None), # no conversation id + ('claude-web', Reply(BAD, 'claude:1', 'length'), None), # unfinished reply + ] + for model, reply, choice in scenarios: + with self.subTest(model=model, choice=choice, cid=reply.conversation_id, finish=reply.finish_reason): + req = self.req.model_copy(update={'model': model, 'tool_choice': choice}) + with patch.object(api, '_chat_with_retry', return_value=reply), \ + self.assertRaises((api.ToolCallError, ConversationStateError)): + api._conversation_tool_chat('p', req, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tool_protocol.py b/tests/test_tool_protocol.py index b8bb320..b91e2da 100644 --- a/tests/test_tool_protocol.py +++ b/tests/test_tool_protocol.py @@ -6,7 +6,8 @@ from unittest.mock import patch from server.openai_format import ToolCallError, completion_response, sse_frames -from server.tool_protocol import validate_reply +from server.tool_protocol import validate_reply as validate_legacy +from server.tool_protocol_new import demote_mistagged_tool_reply, validate_reply from server.recovery_status import recovery_stopped from server.offline_corpus import replay @@ -24,8 +25,7 @@ def fence(payload): class ProtocolCorpusTests(unittest.TestCase): def test_general_envelope_matrix_preserves_every_argument(self): - # Names are deliberately arbitrary: normalization cannot depend on a - # special-cased read/write/edit implementation. + # Main-era DeepSeek tolerances (TOOLCALL_LEGACY=1) live in tool_protocol. for name in ('read', 'write', 'edit', 'arbitrary_custom_tool'): for value in ('', 'quote " slash \\', '\n> text\n```tool_calls', '},{"name":"not_an_action"}', 'кириллица 🚀'): @@ -39,7 +39,8 @@ def test_general_envelope_matrix_preserves_every_argument(self): fence(json.dumps([{'name': name, **args}]))] for index, text in enumerate(variants): with self.subTest(name=name, value=value, variant=index): - content, calls = validate_reply(text, [tool(name)]) + content, calls = validate_legacy( + text, [tool(name)], provider='deepseek') self.assertEqual(content, '') self.assertEqual(len(calls), 2 if index == 2 else 1) for call in calls: @@ -47,7 +48,18 @@ def test_general_envelope_matrix_preserves_every_argument(self): self.assertEqual(json.loads(call['function']['arguments']), args) for finish in ('length', 'error', 'content_filter'): with self.assertRaises(ToolCallError): - validate_reply(text, [tool(name)], finish_reason=finish) + validate_legacy( + text, [tool(name)], provider='deepseek', + finish_reason=finish) + + def test_rework_path_runs_mid_message_blocks_for_any_provider(self): + block = fence('[{"name":"write","arguments":{"file_path":"/x","content":"y"}}]') + text = 'First.\n\n' + block + '\n\nSecond.' + for provider in ('deepseek', 'claude', 'qwen'): + with self.subTest(provider=provider): + content, calls = validate_reply(text, [tool('write')], provider=provider) + self.assertEqual(content, 'First.\n\nSecond.') + self.assertEqual(calls[0]['function']['name'], 'write') def test_invalid_arguments_or_fabricated_receipts_never_return_partial_batch(self): good = json.dumps({'name': 'edit', 'arguments': {'file_path': '/x', 'content': 'x'}}) @@ -61,21 +73,185 @@ def test_invalid_arguments_or_fabricated_receipts_never_return_partial_batch(sel '{"name":"unknown","arguments":{"file_path":"x","content":"x"}}', ] for bad in invalid: - for text in (fence('[' + good + ',' + bad + ']'), - fence('[' + good + ']') + '\nTool (edit): success'): - with self.subTest(text=text), self.assertRaises(ToolCallError): - validate_reply(text, [tool('edit')]) + with self.subTest(bad=bad[:48]), self.assertRaises(ToolCallError): + validate_reply(fence('[' + good + ',' + bad + ']'), [tool('edit')]) + # Rework: valid block + fake tool-result suffix → call + content. + # Main-era (legacy): whole reply must be one block → error. + suffix = fence('[' + good + ']') + '\nTool (edit): success' + content, calls = validate_reply(suffix, [tool('edit')]) + self.assertEqual(len(calls), 1) + self.assertIn('Tool (edit): success', content) + with self.assertRaises(ToolCallError): + validate_legacy(suffix, [tool('edit')], provider='deepseek') + + def test_calls_fence_alias_is_not_a_browser_tool_block(self): + # Browser only recognizes top-level ```tool_calls; ```calls stays prose. + text = 'Shape:\n```calls\n[ { "name": "", "arguments": {} } ]\n```' + for provider in ('claude', 'gemini', 'grok'): + with self.subTest(provider=provider): + content, calls = validate_reply(text, [tool('write')], provider=provider) + self.assertIsNone(calls) + self.assertEqual(content, text) + + BROWSER = ('claude', 'grok', 'mistral', 'chatgpt', 'glm') + CALL = '[{"name": "write", "arguments": {"file_path": "/x", "content": "y"}}]' + + def test_whole_reply_call_works_for_every_browser_provider(self): + for provider in self.BROWSER: + with self.subTest(provider=provider): + content, calls = validate_reply( + '```tool_calls\n' + self.CALL + '\n```', [tool('write')], provider=provider) + self.assertEqual(content, '') + self.assertEqual(calls[0]['function']['name'], 'write') + + def test_valid_call_blocks_run_wherever_they_stand_and_the_rest_stays_content(self): + block = '```tool_calls\n' + self.CALL + '\n```' + sample = '```text\nsome sample\n```' + cases = [ + ('I will write the file now.\n' + block, 'I will write the file now.', 1), + (block + '\nThat was it.', 'That was it.', 1), + ('First.\n\n' + block + '\n\nSecond.', 'First.\n\nSecond.', 1), + ('A\n' + block + '\nB\n' + block + '\nC', 'A\n\nB\n\nC', 2), + ('Sample:\n' + sample + '\n' + block, 'Sample:\n' + sample, 1), + # A closing fence may be missing after a normal stop. + ('Writing.\n```tool_calls\n' + self.CALL, 'Writing.', 1), + ] + for provider in self.BROWSER: + for text, expected, count in cases: + with self.subTest(provider=provider, text=text[:24]): + content, calls = validate_reply(text, [tool('write')], provider=provider) + self.assertEqual(content, expected) + self.assertEqual(len(calls), count) + self.assertTrue(all(c['function']['name'] == 'write' for c in calls)) + with self.subTest(provider=provider, case='unfinished'), self.assertRaises(ToolCallError): + validate_reply(cases[0][0], [tool('write')], provider=provider, finish_reason='length') + + def test_arrays_without_the_tool_calls_fence_are_plain_text(self): + replies = [self.CALL, '```json\n' + self.CALL + '\n```', '```\n' + self.CALL + '\n```', + 'Here is a call:\n\n' + self.CALL, self.CALL + '\nThat was it.', + '[{"name": "write", "arguments": {"bad": 1}}]', + '```calls\n' + self.CALL + '\n```'] + for provider in self.BROWSER: + for reply in replies: + with self.subTest(provider=provider, reply=reply[:14]): + content, calls = validate_reply(reply, [tool('write')], provider=provider) + self.assertIsNone(calls) + self.assertEqual(content, reply) + + def test_invalid_tool_blocks_fail_validate_after_demote_layer(self): + # Advice (no name / unknown+prose) is demoted in the API layer. Remaining + # top-level ```tool_calls must parse: syntax and no_name are errors here. + cases = [ + ('Shape:\n```tool_calls\n[ { ... }, { ... } ]\n```\nUse it.', 'syntax'), + ('Example:\n```tool_calls\n[{"name": "tool_name", "arguments": {...}}]\n```', 'syntax'), + ('Missing comma:\n```tool_calls\n[{"name": "write" "arguments": {}}]\n```', 'syntax'), + ('No name:\n```tool_calls\n[{"arguments": {"file_path": "/x"}}]\n```', 'no_name'), + ('Not a call:\n```tool_calls\n{"hello": "world"}\n```', 'no_name'), + ('Empty:\n```tool_calls\n[]\n```', 'no_name'), + ] + for provider in self.BROWSER: + for text, kind in cases: + with self.subTest(provider=provider, kind=kind, text=text[:24]): + with self.assertRaises(ToolCallError) as ctx: + validate_reply(text, [tool('write')], provider=provider) + self.assertEqual(ctx.exception.kind, kind) + # tool_choice none: never an error from block parse. + content, calls = validate_reply(cases[0][0], [tool('write')], 'none', provider='claude') + self.assertIsNone(calls) + self.assertEqual(content, cases[0][0]) + + def test_unknown_tool_and_wrong_arguments_are_errors_with_a_kind(self): + demo = ('```tool_calls\n[{"name": "functions.my_fake_tool", "arguments": ' + '{"someString": "x"}}]\n```') + wrong = '```tool_calls\n[{"name": "write", "arguments": {"file_path": "/x"}}]\n```' + valid = '```tool_calls\n' + self.CALL + '\n```' + for provider in self.BROWSER: + with self.subTest(provider=provider, case='unknown'): + with self.assertRaises(ToolCallError) as ctx: + validate_reply('Example:\n' + demo + '\nDone.', [tool('write')], provider=provider) + self.assertEqual(ctx.exception.kind, 'unknown_tool') + self.assertEqual(ctx.exception.tool, 'functions.my_fake_tool') + with self.subTest(provider=provider, case='schema'): + with self.assertRaises(ToolCallError) as ctx: + validate_reply('Now:\n' + wrong, [tool('write')], provider=provider) + self.assertEqual(ctx.exception.kind, 'schema') + self.assertEqual(ctx.exception.tool, 'write') + self.assertIn('file_path: string', ctx.exception.guidance) + self.assertNotIn('/x', ctx.exception.guidance) + with self.subTest(provider=provider, case='mixed'): + # One bad call stops the whole message; nothing runs. + for text in (valid + '\nAnd:\n' + demo, wrong + '\n' + valid): + with self.assertRaises(ToolCallError): + validate_reply(text, [tool('write')], provider=provider) + with self.subTest(provider=provider, case='none'): + # tool_choice none: an attempted call is shown, never an error. + content, calls = validate_reply(demo, [tool('write')], 'none', provider=provider) + self.assertIsNone(calls) + # LEGACY=0: DeepSeek uses the same validate as browser (unknown → error). + with self.assertRaises(ToolCallError) as ctx: + validate_reply(demo, [tool('write')], provider='deepseek') + self.assertEqual(ctx.exception.kind, 'unknown_tool') + + def test_demote_mistagged_advice_rewrites_fence_to_text(self): + tools = [tool('write')] + no_name = 'Advice:\n```tool_calls\n[{"arguments": {"file_path": "/x"}}]\n```\nDone.' + broken_no_name = 'Shape:\n```tool_calls\n[ { ... }, { ... } ]\n```\nUse it.' + unknown_prose = ( + 'Example:\n```tool_calls\n[{"name": "functions.my_fake_tool", ' + '"arguments": {"someString": "x"}}]\n```\nDone.') + placeholder = ( + 'Placeholder:\n```tool_calls\n' + '[{"name": "tool_name", "arguments": {...}}]\n```') + for text in (no_name, broken_no_name, unknown_prose, placeholder): + with self.subTest(text=text[:28]): + demoted = demote_mistagged_tool_reply(text, tools) + self.assertIsNotNone(demoted) + self.assertIn('```text', demoted) + self.assertNotIn('```tool_calls', demoted) + self.assertIn(text.split('```tool_calls', 1)[0], demoted) + + def test_demote_leaves_real_tool_attempts_alone(self): + tools = [tool('write')] + valid = '```tool_calls\n' + self.CALL + '\n```' + unknown_alone = ( + '```tool_calls\n[{"name": "functions.my_fake_tool", ' + '"arguments": {"someString": "x"}}]\n```') + schema = ( + '```tool_calls\n[{"name": "write", "arguments": ' + '{"file_path": "/x"}}]\n```') + known_broken = ( + '```tool_calls\n[{"name": "write" "arguments": ' + '{"file_path": "/x", "content": "y"}}]\n```') + mixed = 'Sample:\n```tool_calls\n[ { ... } ]\n```\n' + valid + for text in (valid, unknown_alone, schema, known_broken, mixed): + with self.subTest(text=text[:40]): + self.assertIsNone(demote_mistagged_tool_reply(text, tools)) + + def test_quoted_blocks_stay_text_for_browser_providers(self): + block = '```tool_calls\n' + self.CALL + '\n```' + for text in ('```markdown\n' + block + '\n```', + '\n'.join('> ' + line for line in block.splitlines())): + with self.subTest(text=text[:20]): + content, calls = validate_reply(text, [tool('write')], provider='claude') + self.assertIsNone(calls) + + def test_legacy_keeps_strict_single_block_for_http_providers(self): + block = '```tool_calls\n' + self.CALL + '\n```' + with self.assertRaises(ToolCallError): + validate_legacy( + 'Intro.\n' + block + '\nDone.', [tool('write')], provider='qwen') + self.assertIsNone( + validate_legacy(self.CALL, [tool('write')], provider='qwen')[1]) def test_quoted_code_never_becomes_an_action(self): action = fence('[{"name":"edit","arguments":{"file_path":"x","content":"x"}}]') + # find_tool_blocks only nests under ``` fences (not ~~~). for text in ('\n'.join('> ' + s for s in action.splitlines()), - '```markdown\n' + action + '\n```', - '~~~markdown\n' + action + '\n~~~'): - try: - _, calls = validate_reply(text, [tool('edit')]) - except ToolCallError: - continue - self.assertIsNone(calls) + '```markdown\n' + action + '\n```'): + with self.subTest(text=text[:24]): + content, calls = validate_reply(text, [tool('edit')]) + self.assertIsNone(calls) + self.assertEqual(content, text) def test_recovery_stop_is_normal_completion_and_never_claims_goal_success(self): with patch('socket.socket', side_effect=AssertionError('network forbidden')): diff --git a/tests/test_toolcall_logging.py b/tests/test_toolcall_logging.py new file mode 100644 index 0000000..6d68896 --- /dev/null +++ b/tests/test_toolcall_logging.py @@ -0,0 +1,97 @@ +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +from server import toolcall_logging + + +class ToolcallLoggingTests(unittest.TestCase): + def test_disabled_by_default_and_enabled_only_by_one(self): + with patch.dict(os.environ, {}, clear=True): + self.assertFalse(toolcall_logging.enabled()) + with patch.dict(os.environ, {"TOOLCALL_LOG": "0"}): + self.assertFalse(toolcall_logging.enabled()) + with patch.dict(os.environ, {"TOOLCALL_LOG": "1"}): + self.assertTrue(toolcall_logging.enabled()) + + def test_new_chat_allocates_unique_file_and_writes_sections(self): + log_dir = Path(__file__).resolve().parents[1] / "test-logs-fixture" + files = set() + chunks = {} + + def fake_mkdir(path, *args, **kwargs): + return None + + def fake_exists(path): + return Path(path) in files + + def fake_open(path, flags, mode=0o777, *args, **kwargs): + path = Path(path) + if path.parent != log_dir: + return os.open(str(path), flags, mode) + if path in files: + raise FileExistsError(str(path)) + files.add(path) + return 1 + + class MemoryWriter: + def __init__(self, path): + self.path = path + def __enter__(self): + return self + def __exit__(self, *args): + return False + def write(self, value): + chunks.setdefault(self.path, []).append(value) + + with patch.object(toolcall_logging, "LOG_DIR", log_dir), \ + patch.dict(os.environ, {"TOOLCALL_LOG": "1"}), \ + patch.object(Path, "mkdir", fake_mkdir), \ + patch.object(Path, "exists", fake_exists), \ + patch.object(Path, "chmod", lambda *a, **k: None), \ + patch.object(os, "open", fake_open), \ + patch.object(os, "close", lambda fd: None), \ + patch.object(Path, "open", lambda path, *a, **k: MemoryWriter(path)): + first = toolcall_logging.new_chat("my/provider") + second = toolcall_logging.new_chat("my/provider") + self.assertIsNotNone(first) + self.assertEqual(first.parent, log_dir) + self.assertRegex(first.name, r"^\d{10}-my_provider(?:-\d+)?\.log$") + self.assertNotEqual(first, second) + for direction, payload in ( + ("from_agent", '{"messages": []}'), + ("to_provider", "prompt\nexact"), + ("from_provider", "reply"), + ("to_agent", '{"choices": []}'), + ("to_agent", {"choices": [], "content": "Привет"}), + ("to_agent", type("Response", (), {"body": b'{"error":{"message":"nope"}}'})()), + ("to_agent", 'data: {"id":"chunk-1","choices":[{"delta":{"content":"hello"}}]}\\n\\n'), + ("to_agent", 'data: {"id":"chunk-2","choices":[{"delta":{"content":"```tool_calls id=\\"c1\\"\\n[{\\"name\\":\\"read\\",\\"arguments\\":{\\"file_path\\":\\"a.py\\"}}]\\n```"},"finish_reason":null}]}'), + ("to_agent", 'data: {"id":"chunk-3","choices":[{"delta":{"content":"real\\nline"}}]}'), + ("add_chat", {"key": "history-key", "used": 0}), + ("search_chat", {"key": "history-key"}), + ("consume_chat", {"key": "history-key", "used": 1}), + ): + toolcall_logging.record(first, direction, payload) + text = "".join(chunks[first]) + for marker in toolcall_logging.SEPARATOR.values(): + self.assertIn(marker + "\n", text) + self.assertIn("prompt\nexact\n", text) + self.assertIn('{\n "messages": []\n}', text) + self.assertIn('{\n "choices": []\n}', text) + self.assertIn('{\n "choices": [],\n "content": "Привет"\n}', text) + self.assertIn('{\n "error": {\n "message": "nope"\n }\n}', text) + self.assertIn('data: {\n "id": "chunk-1",\n "choices": [\n {\n "delta": {\n "content": "hello"\n }\n }\n ]\n}', text) + self.assertIn('data: {\n "id": "chunk-2",\n "choices": [', text) + self.assertIn('```tool_calls id=\\"c1\\"\\n[{\\"name\\":\\"read\\",\\"arguments\\":{\\"file_path\\":\\"a.py\\"}}]\\n```', text) + self.assertIn('data: {\n "id": "chunk-3",\n "choices": [\n {\n "delta": {\n "content": "real\\nline"', text) + + + def test_disabled_logging_does_not_create_directory(self): + with patch.dict(os.environ, {"TOOLCALL_LOG": "0"}): + self.assertIsNone(toolcall_logging.new_chat("deepseek")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tools.py b/tests/test_tools.py index b3dd820..98ac150 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -119,6 +119,17 @@ def test_required_fields_reminder_uses_schema_without_inventing_values(self): parse_tool_calls(block([{'name': 'write', 'arguments': {'content': 'data'}}]), {'write'}, flat_argument_tools=tools) + def test_tool_calls_fence_accepts_metadata_after_label(self): + text = ('```tool_calls id="z8q3md"\n' + '[{"name":"read","arguments":{"file_path":"server/api.py",' + '"offset":1032,"limit":125}}]\n```') + content, calls = parse_tool_calls(text, {"read"}, flat_argument_tools=FLAT_TOOLS) + self.assertEqual(content, "") + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["function"]["name"], "read") + self.assertEqual(json.loads(calls[0]["function"]["arguments"]), + {"file_path": "server/api.py", "offset": 1032, "limit": 125}) + def test_generic_call_array_can_request_correction_but_never_execute(self): payload = [{"name": "bash", "arguments": {"command": "pwd && ls -la"}}] for language in ('json', ''): @@ -316,10 +327,59 @@ def test_light_tool_instructions_use_reminder_only_on_continuation(self): tools_continuation=True, tool_instructions="light") self.assertIn("Available tools, given as JSON Schema", first) self.assertIn('"name": "write"', first) + self.assertIn("Developer: The preceding transcript", first) + self.assertIn("User: edit", first) + plain = messages_to_prompt( + [ChatMessage(role="user", content="edit")], tools=None, + tool_instructions="light") + self.assertEqual(plain, "edit") self.assertIn("Tools remain available from earlier in this chat: write", follow) self.assertNotIn("Available tools, given as JSON Schema", follow) self.assertNotIn('"required": ["path"]', follow) + def test_medium_reminder_carries_argument_shapes_without_descriptions(self): + tools = [{"type": "function", "function": { + "name": "ask_user_question", "description": "SECRET-DESCRIPTION", + "parameters": {"type": "object", "required": ["questions"], "properties": { + "questions": {"type": "array", "items": { + "type": "object", "required": ["question", "options"], "properties": { + "question": {"type": "string"}, + "header": {"type": "string", "description": "SECRET-FIELD-DOC"}, + "multiple": {"type": "boolean"}, + "options": {"type": "array", "items": { + "type": "object", "required": ["label"], + "properties": {"label": {"type": "string"}, + "description": {"type": "string"}}}}}}}}}}}] + follow = messages_to_prompt( + [ChatMessage(role="user", content="ask me")], tools=tools, + tools_continuation=True, tool_instructions="medium") + self.assertIn( + "- ask_user_question: {questions: [{question: string, header?: string, " + "multiple?: boolean, options: [{label: string, description?: string}]}]}", follow) + self.assertNotIn("SECRET-DESCRIPTION", follow) + self.assertNotIn("SECRET-FIELD-DOC", follow) + self.assertNotIn("Available tools, given as JSON Schema", follow) + self.assertIn("Tools remain available from earlier in this chat: ask_user_question", follow) + # Medium still sends the full block on the first turn. + first = messages_to_prompt( + [ChatMessage(role="user", content="ask me")], tools=tools, + tools_continuation=False, tool_instructions="medium") + self.assertIn("Available tools, given as JSON Schema", first) + # Light is the original names-only reminder: no argument shapes. + light = messages_to_prompt( + [ChatMessage(role="user", content="ask me")], tools=tools, + tools_continuation=True, tool_instructions="light") + self.assertIn("Tools remain available from earlier in this chat: ask_user_question", light) + self.assertNotIn("Argument shapes", light) + self.assertNotIn("questions:", light) + + def test_tool_instruction_mode_accepts_medium_and_rejects_unknown(self): + from server.config import _tool_instructions_mode + with patch.dict("os.environ", {"X_MODE": "Medium"}): + self.assertEqual(_tool_instructions_mode("X_MODE", "light"), "medium") + with patch.dict("os.environ", {"X_MODE": "heavy"}), self.assertRaises(ValueError): + _tool_instructions_mode("X_MODE", "light") + def test_none_tool_instructions_omit_block_on_continuation(self): tools = [{ "type": "function", "function": {"name": "write", "parameters": { @@ -350,6 +410,25 @@ def test_browser_provider_defaults_to_light_http_to_full(self): self.assertIn("Tools remain available from earlier in this chat", browser_follow) self.assertIn("Available tools, given as JSON Schema", http_follow) + def test_browser_tool_result_continuation_sends_only_the_result(self): + tools = [{"type": "function", "function": {"name": "write", "parameters": {"type": "object"}}}] + result = ChatMessage(role="tool", tool_call_id="call_1", name="write", content='{"ok": true}') + for mode in ("full", "medium", "light", "none"): + with self.subTest(mode=mode): + sent = messages_to_prompt([result], tools=tools, tools_continuation=True, + tool_instructions=mode, tool_results_compact=True) + self.assertEqual(sent, 'Tool result (write id="call_1"): {"ok": true}') + # The reminder stays for a plain user message, and nothing changes without the flag. + user = messages_to_prompt([ChatMessage(role="user", content="again")], tools=tools, + tools_continuation=True, tool_instructions="light", + tool_results_compact=True) + self.assertIn("Tools remain available from earlier in this chat", user) + plain = messages_to_prompt([result], tools=tools, tools_continuation=True, + tool_instructions="light") + self.assertIn("Tool (write, call_id=call_1)", plain) + self.assertIn("Tools remain available from earlier in this chat", plain) + self.assertTrue(plain.endswith("Assistant:")) + def test_json_diagnostics_report_only_category_and_position(self): bodies = [ ('[{"name":"write","arguments":{"content":"private\nvalue"}}]', @@ -428,6 +507,20 @@ def test_closed_invalid_protocol_does_not_trigger_continuation(self): def test_markdown_inside_cut_arguments_does_not_hide_truncation(self): self.assertTrue(looks_truncated('```tool_calls\n[{"name":"write","arguments":{"content":"```code')) + def test_mid_message_unclosed_tool_fence_looks_truncated(self): + # Browser replies may put the protocol block after prose; continuation + # must still see the open top-level fence (not only whole-reply starts). + cut = ('I will write the file now.\n\n```tool_calls\n' + '[{"name":"write","arguments":{"file_path":"/x","content":"partial') + self.assertTrue(looks_truncated(cut, {"write"})) + self.assertTrue(looks_truncated(cut)) + closed = ('Intro.\n```tool_calls\n' + '[{"name":"write","arguments":{"file_path":"/x","content":"y"}}]\n```') + self.assertFalse(looks_truncated(closed)) + # Quoted / nested open fences do not count. + nested = '```markdown\n```tool_calls\n[{"name":"write"\n```' + self.assertFalse(looks_truncated(nested)) + def test_tool_choice_is_enforced(self): _, bash = parse_tool_calls(block([{"name":"bash","arguments":{}}]), {"bash","write"}) for choice, calls in [("none",bash),("required",None),