8 Commits

Author SHA1 Message Date
NCBM d3f8132b5d core/agent: note the live-probe result for the echoed blocks
A search round goes back with and without a local `tool_use` after its blocks: the endpoint takes both, and the model answers from the results it already holds. With the blocks stripped it calls its own earlier answer fabricated instead. The note sits where the ordering decision lives, so the shape needs no re-probe.
2026-10-09 20:15:38 +08:00
NCBM b0116a67c1 docs: describe the kept provider blocks 2026-10-09 20:09:43 +08:00
NCBM f89c8dec31 lmproto/openai_compatible: drop provider blocks on a chat wire
`provider_blocks` means something only to the surface that produced them (Anthropic `thinking` / server-tool blocks). A session that switched surface would otherwise send those dicts to a chat-completions provider as an assistant-message key it does not know; the request normalization drops the field, on a copy, so the history keeps it.
2026-10-09 20:09:36 +08:00
NCBM 7a54642ecf core/agent: rebuild a streamed round's provider blocks
Streaming is the default path: `thinking` fragments, a `server_tool_use`'s `partial_json` query and a `web_search_tool_result`'s entries arrive as separate SSE events, so the blocks the round has to hand back exist only as fragments. The dispatch rebuilds each one from its start payload (field for field, under the stream's own names) plus the fragments that follow, and passes it to the loop as a chunk; the loop copies it onto the round's message, where a round that kept only those blocks counts as payload rather than an empty completion.

The thinking text streams as reasoning as it arrives — the search query and the signature go back with the round only.
2026-10-09 20:09:29 +08:00
NCBM 2fdbc3ce5d core/agent: keep a Messages round's provider blocks on the assistant message
DeepSeek's Anthropic compat answers a hosted search inside the turn: the response carries the `thinking` that picked the query, the `server_tool_use`, and the `web_search_tool_result`. The response mapping kept text and tool_use only, so the round lost its evidence — and the `thinking` signature, which only the provider can verify, could not go back at all. `AssistantChatMessage.provider_blocks` carries those blocks as raw JSON now (`thinking` text *and* signature, the query, the result entries with an `encrypted_content` the API rejects when trimmed) and the assistant turn hands them back first, ahead of its text and tool_use blocks; `thinking` also lands in `reasoning_content`, so the chain of thought streams as reasoning like every other surface's.

`AnthropicRawContentBlock` names the provider fields a streamed block carries, and `AnthropicServerToolUseContent` keeps the `caller` metadata the wire sends.
2026-10-09 20:09:20 +08:00
NCBM 6c4c8b9328 core/tools: edit_lineno rejections name the read_file call 2026-10-09 19:46:16 +08:00
NCBM 279cbe9389 core/tools: document fetch's 0-based body offset 2026-10-09 19:46:09 +08:00
NCBM 38e2dd58b0 core/config: state the 0-based offsets of skill_read and fetch 2026-10-09 19:46:02 +08:00
17 changed files with 596 additions and 48 deletions
+3 -3
View File
@@ -50,7 +50,7 @@ lmproto/deepseek/anthropic/ ← DeepSeek Anthropic client (extends Anthropic
- **`openai_compatible/config.py`** — `OpenAIConfig` (token + base URL).
- **`openai/model.py`** — OpenAI Responses API (`ResponsesCreateParam`, `Response`, function_call items, stream events).
- **`openai/client.py`** — platform `OpenAIClient` (`kind="responses"`): chat completions + `responses` / `get_response` / `delete_response`.
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts.
- **`anthropic/model.py`** — Anthropic Messages request/response + SSE event models; the response content union also carries the server-tool blocks DeepSeek's compat returns (`thinking`, `server_tool_use`, `web_search_tool_result` with its verbatim `encrypted_content`), and `tools` accepts opaque server-tool dicts. `AnthropicRawContentBlock` names the same fields on the streaming payloads, and an assistant message's `content` accepts the opaque block dicts it echoes back.
- **`anthropic/client.py`** — `AnthropicClient` (`kind="messages"`): `POST /messages` + `GET /models`.
- **DeepSeek** — `DeepseekOpenAIClient` (`kind="chat_completions"`); models add `reasoning_content`, `prefix`, `ThinkingOptions`, and it injects the `thinking` flag (`enabled`, or `disabled` for `reasoning_effort = "none"`) after encoding, plus an empty `reasoning_content` on any assistant message that carries none (thinking mode refuses any round — a call or a plain answer — whose reasoning never came back). Config default model ids: `deepseek-flash`, `deepseek-v4-pro`. `DeepseekResponsesClient` (`kind="responses"`) extends the OpenAI Responses client for `convention = "responses"` (SSE stops at terminal events — DeepSeek sends no `[DONE]`). `DeepseekAnthropicClient` (`kind="messages"`) extends `AnthropicClient` for `convention = "anthropic"` (base `https://api.deepseek.com/anthropic`; `models()` returns `[]`).
@@ -78,8 +78,8 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
- **Kind-based dispatch**: clients expose `kind` (`chat_completions` | `responses` | `messages`). Agent history stays chat-completions-shaped; transport adapters return synthetic `ChatCompletionResponse` / stream chunks.
- **`responses_bridge` / `responses_dispatch`**: chat ↔ OpenAI Responses; stream text/reasoning deltas (`response.reasoning_text.done` repeats what its fragments spelled out, so the loop streams each part once and still keeps the whole text in the round's message); tool calls + usage on `response.completed`; a request carrying `tools` returns every round's reasoning as a `reasoning` item of `reasoning_text` parts — "必须完整回传 `reasoning_content` … 即使该轮模型未实际进行工具调用" (a missing item and an empty part are both a 400, so a round that kept none sends a blank line) — and a round's `function_call` items stay contiguous ahead of their outputs (`call, output, call, output` was a 400: the second call reads as a round of its own without reasoning); reasoning is read back from the item's `content` parts.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call, and the blocks are read (see `lmproto/anthropic/model.py`) but never executed.
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results must be passed back verbatim if they are kept at all) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`messages_bridge` / `messages_dispatch`**: chat ↔ Anthropic Messages; system fold; tool_use/tool_result; stream text + tool argument fragments; `thinking` from the request's `reasoning_effort` (level → token budget, floor 1024, `max_tokens` grown when the budget would leave no room) plus the client's configured `thinking_budget_tokens`; a server tool (`server_tool_use` + its `web_search_tool_result`) is the surface's own business — it streams `input_json_delta` fragments like a call but nothing local asked for it, so only a `tool_use` block becomes a tool call and nothing is executed; `thinking` and the two search blocks are kept as `AssistantChatMessage.provider_blocks` (the thinking text also lands in `reasoning_content`, so it streams as reasoning) and go back with the round, which is also why a round that kept only those blocks is not an empty completion; a chat-completions request drops the field (`compat.coerce_chat_messages`).
- **Provider-side tools**: `provider_tools` on the OpenAI **and** DeepSeek presets (list of dicts; `[{type="web_search"}]` when omitted; `[]` disables) threaded via `ChatAgent`/`run_chat_loop` into Responses and Messages; never executed by `ToolRegistry`. The Messages surface declares its own server tool instead — a bare `web_search` becomes `{type="web_search_20250305", name="web_search"}` (the search then runs inside the response, and its `encrypted_content` results go back verbatim with the round — `AssistantChatMessage.provider_blocks`) — and a DeepSeek provider on a surface that ignores hosted tools (`config.routing.local_web_search`) gets the local `web_search` tool from `tools/net/search.py` instead.
- **`@tool` / `ToolRegistry`**: decorator infers JSON Schema from type hints; execute tools by name. Before running, `execute` answers a call that omits a required argument with the accepted names (`_missing_required_error`) — the handler's bare `TypeError` never reaches the model, and no confirm prompt fires for a call that cannot run. Shape mistakes inside a *present* argument are the tool's own job (see the `chat` bullet).
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`; resolved `reasoning_effort` on every request (see Config).
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; **completed tool batches checkpointed** mid-turn; unfinished assistant suffix rolled back on failure); `stream`; system prompt; `provider_tools`; `retry()` continues incomplete turns (user-only **or** after committed tools — does not re-run those tools).
+4 -2
View File
@@ -21,11 +21,13 @@ Tool playbook still applies (see system). Hard constraints:
use `run_argv_batch` for ordered pipelines.
- `edit_replace`: fix match / `max_replaces`; `edit_lineno` edits only lines read via \
`read_file(with_lineno=true)`, and line numbers reset after any file write.
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based).
- `read_file` results start with `L{begin}-{end}` (1-based lines; offset is 0-based; \
`skill_read` slices a skill file the same way).
- `regex_files`: `pattern` is a Python `re` regex (not a glob, so `*.py` fails) and \
`path` is a literal file/dir (no glob expansion).
- Truncated results carry a `truncate_token=...`; continue with `get_truncated`.
- Prefer `fetch` for HTTP(S); private/LAN hosts need human policy allow (not YOLO).
- Prefer `fetch` for HTTP(S); `offset` (0-based) skips leading body characters; \
private/LAN hosts need human policy allow (not YOLO).
- PTY secrets only via `ask_into_pty`; denylists and confirms still apply.
- `wait` pauses; pressing Enter disturbs early (optional reason).
- Todo stack: open items mean unfinished work; the stack does not auto-clear — \
+26 -3
View File
@@ -136,11 +136,16 @@ async def _execute_tool_calls(
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
"""True if the model produced text, reasoning, or tool calls."""
"""True if the model produced text, reasoning, provider blocks, or tool calls."""
if assistant.tool_calls is not UNSET and assistant.tool_calls:
return True
if isinstance(assistant.content, str) and assistant.content.strip():
return True
# A round that only searched (a hosted ``web_search``) kept the search
# blocks as provider blocks: the model did produce something, even with no
# text of its own yet.
if assistant.provider_blocks is not UNSET and assistant.provider_blocks:
return True
reasoning = assistant.reasoning_content
return bool(isinstance(reasoning, str) and reasoning.strip())
@@ -283,6 +288,23 @@ def _take_reasoning_delta(
return [content], [ReasoningDeltaEvent(content=fresh)] if fresh else []
def _accumulate_deltas(
delta: DeltaMessage,
*,
tool_deltas: list[StreamToolCallDelta],
provider_blocks: list[dict[str, Any]],
) -> None:
"""Collect the carriers a round keeps from one chunk's delta.
Tool calls merge after the stream; provider-opaque blocks (a hosted search's
blocks, a thinking block) were rebuilt by the bridge and ride along as-is.
"""
if delta.tool_calls is not UNSET and delta.tool_calls:
tool_deltas.extend(delta.tool_calls)
if delta.provider_blocks is not UNSET and delta.provider_blocks:
provider_blocks.extend(delta.provider_blocks)
async def _stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
@@ -306,6 +328,7 @@ async def _stream_round(
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
provider_blocks: list[dict[str, Any]] = []
tool_deltas: list[StreamToolCallDelta] = []
last_api_usage: object = UNSET
finish_reason: str | None = None
@@ -332,8 +355,7 @@ async def _stream_round(
if isinstance(delta.content, str) and delta.content:
content_parts.append(delta.content)
yield TextDeltaEvent(content=delta.content)
if delta.tool_calls is not UNSET and delta.tool_calls:
tool_deltas.extend(delta.tool_calls)
_accumulate_deltas(delta, tool_deltas=tool_deltas, provider_blocks=provider_blocks)
full_content = "".join(content_parts)
full_reasoning = "".join(reasoning_parts)
@@ -347,6 +369,7 @@ async def _stream_round(
content=full_content or None,
tool_calls=tool_calls,
reasoning_content=full_reasoning or UNSET,
provider_blocks=provider_blocks or UNSET,
)
# Usage-only final chunks leave choices empty; a non-empty usage after
# content still is not a finish_reason. Prefer explicit finish_reason.
+59 -6
View File
@@ -18,15 +18,17 @@ from plyngent.lmproto.anthropic.model import (
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicResponseText,
AnthropicResponseToolUse,
AnthropicServerToolUseContent,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicThinkingContent,
AnthropicToolChoice,
AnthropicToolDefinition,
AnthropicToolResultContent,
AnthropicToolUseContent,
AnthropicUsage,
AnthropicUserMessage,
AnthropicWebSearchToolResultContent,
)
from plyngent.lmproto.openai_compatible.model import (
AnyAssistantToolCall,
@@ -133,7 +135,19 @@ def _tool_choice_from_chat(param: ChatCompletionsParam) -> AnthropicToolChoice |
def _assistant_to_anthropic(message: AssistantChatMessage) -> AnthropicAssistantMessage | None:
blocks: list[AnthropicTextContent | AnthropicToolUseContent] = []
"""The assistant turn for the wire: opaque blocks, then text, then calls.
``provider_blocks`` go back first and verbatim (a ``thinking`` block's
signature is only the provider's to verify, and a hosted search's
``web_search_tool_result`` entries carry an ``encrypted_content`` the API
rejects when trimmed), which is also the order the API sends them in. A live
probe takes that shape back — the round after a search, with and without a
local ``tool_use`` after the blocks — and the model then answers from the
results it already holds instead of searching again.
"""
blocks: list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]] = []
if message.provider_blocks is not UNSET and message.provider_blocks:
blocks.extend(message.provider_blocks)
if isinstance(message.content, str) and message.content:
blocks.append(AnthropicTextContent(text=message.content))
if message.tool_calls is not UNSET and message.tool_calls:
@@ -219,22 +233,59 @@ def chat_messages_to_anthropic( # noqa: C901 — multi-role conversion
return system, out
def _thinking_block_json(block: AnthropicThinkingContent) -> dict[str, Any]:
"""A ``thinking`` block as JSON: its text, plus the signature when sent."""
out: dict[str, Any] = {"type": "thinking", "thinking": block.thinking}
if isinstance(block.signature, str):
out["signature"] = block.signature
return out
def _server_block_json(
block: AnthropicServerToolUseContent | AnthropicWebSearchToolResultContent,
) -> dict[str, Any]:
"""A hosted search's block as JSON: the query it ran, or the raw entries.
Rebuilt from the parsed block: an ``encrypted_content`` (and any other key
inside the loose ``content`` entries) survives byte for byte, while a key
the block model does not name cannot come back.
"""
if isinstance(block, AnthropicServerToolUseContent):
out: dict[str, Any] = {"type": "server_tool_use", "id": block.id, "name": block.name, "input": block.input}
if block.caller is not UNSET:
out["caller"] = block.caller
return out
return {"type": "web_search_tool_result", "tool_use_id": block.tool_use_id, "content": block.content}
def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> AssistantChatMessage:
"""Map a completed Anthropic message to agent ``AssistantChatMessage``.
Server-side blocks (``server_tool_use`` / ``web_search_tool_result``) and
``thinking`` are not part of the chat-shaped message: a search the model ran
through its backend already shows up in the text it answered with.
``thinking`` lands in ``reasoning_content`` (the chat-shaped slot every
surface's chain-of-thought goes into) and, signature included, in
``provider_blocks`` — the API wants the block back to continue a turn it
reasoned through. A hosted search's ``server_tool_use`` +
``web_search_tool_result`` pair is not a call for the local registry, but it
is the model's own evidence: both go into ``provider_blocks`` verbatim, so
the next request hands them back and the model does not search again.
"""
text_parts: list[str] = []
thinking_parts: list[str] = []
provider_blocks: list[dict[str, Any]] = []
tool_calls: list[AnyAssistantToolCall] = []
for block in response.content:
if isinstance(block, AnthropicResponseText):
if block.text:
text_parts.append(block.text)
continue
if not isinstance(block, AnthropicResponseToolUse):
if isinstance(block, AnthropicThinkingContent):
thinking_parts.append(block.thinking)
provider_blocks.append(_thinking_block_json(block))
continue
if isinstance(block, (AnthropicServerToolUseContent, AnthropicWebSearchToolResultContent)):
provider_blocks.append(_server_block_json(block))
continue
# The union's remaining member is the local tool call.
tool_calls.append(
AssistantFunctionToolCall(
id=block.id,
@@ -248,6 +299,8 @@ def anthropic_response_to_assistant(response: AnthropicMessageResponse) -> Assis
return AssistantChatMessage(
content=text or None,
tool_calls=tool_calls or UNSET,
reasoning_content="".join(thinking_parts) or UNSET,
provider_blocks=provider_blocks or UNSET,
)
+135 -5
View File
@@ -8,7 +8,8 @@ enter it.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Final
import json
from typing import TYPE_CHECKING, Any, Final, cast
from plyngent.agent.messages_bridge import (
anthropic_response_to_chat_completion,
@@ -18,6 +19,8 @@ from plyngent.agent.messages_bridge import (
)
from plyngent.agent.stream_chunks import (
finish_reason_chunk,
provider_blocks_chunk,
reasoning_delta_chunk,
text_delta_chunk,
tool_call_delta_chunk,
usage_chunk,
@@ -25,6 +28,7 @@ from plyngent.agent.stream_chunks import (
from plyngent.lmproto.anthropic.model import (
AnthropicContentBlockDelta,
AnthropicContentBlockStart,
AnthropicContentBlockStop,
AnthropicErrorEvent,
AnthropicMessageDelta,
AnthropicMessageStart,
@@ -65,6 +69,92 @@ class _ToolBlockState:
self.tool_index = tool_index
# Block types the chat-shaped round carries itself: text becomes the message's
# content and ``tool_use`` a local tool call. Every other type is the surface's
# own (``thinking``, ``server_tool_use``, ``web_search_tool_result``) and is
# rebuilt so the round can hand it back.
_LOCAL_BLOCK_TYPES: Final = frozenset({"text", "tool_use"})
# The two fields a *delta* owns; a block's own JSON skips them.
_DELTA_ONLY_FIELDS: Final = frozenset({"text", "partial_json"})
def _raw_block_json(block: AnthropicRawContentBlock) -> dict[str, Any]:
"""The block's own JSON: every field the stream set, under its own name.
Field-driven, so a field added to the block model comes along without a
second list to keep in sync.
"""
out: dict[str, Any] = {"type": block.type}
for name in block.__struct_fields__:
if name in _DELTA_ONLY_FIELDS:
continue
value: object = getattr(block, name)
if value is not None:
out[name] = value
return out
def _parse_input_fragments(fragments: list[str]) -> dict[str, Any]:
"""The ``input`` a ``server_tool_use`` streamed as ``partial_json`` chunks."""
try:
parsed = json.loads("".join(fragments))
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return cast("dict[str, Any]", parsed)
return {}
class _ProviderBlockState:
"""A surface-owned block rebuilt from its start payload and fragments.
Nothing local asked for it, but it goes back on the wire with the round (see
``AssistantChatMessage.provider_blocks``): a ``thinking`` block's text and
its opaque ``signature``, and the search query a ``server_tool_use`` streams
as ``partial_json``.
"""
__slots__: Final = ("json_fragments", "parts", "raw", "signature")
raw: dict[str, Any]
parts: list[str]
json_fragments: list[str]
signature: str
def __init__(self, raw: dict[str, Any]) -> None:
self.raw = raw
self.parts = []
self.json_fragments = []
self.signature = ""
def add(self, delta: AnthropicRawContentBlock) -> str | None:
"""Fold one fragment in; return the thinking text it carried, if any."""
if isinstance(delta.partial_json, str) and delta.partial_json:
self.json_fragments.append(delta.partial_json)
return None
if isinstance(delta.signature, str) and delta.signature:
self.signature = delta.signature
return None
# Anthropic streams a thinking fragment as ``thinking``; DeepSeek's
# compat also spells the same fragment ``text``.
fragment = delta.thinking if isinstance(delta.thinking, str) else delta.text
if not isinstance(fragment, str) or not fragment:
return None
self.parts.append(fragment)
return fragment
def finish(self) -> dict[str, Any]:
"""The block to hand back, fragments folded into its start payload."""
block = dict(self.raw)
if self.parts:
block["thinking"] = f"{block.get('thinking', '')}{''.join(self.parts)}"
if self.json_fragments:
block["input"] = _parse_input_fragments(self.json_fragments)
if self.signature:
block["signature"] = self.signature
return block
def _merge_usage(previous: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, Any]:
"""Merge ``message_start`` usage with the later ``message_delta`` usage.
@@ -99,6 +189,7 @@ class _StreamState:
model: str
tool_blocks: dict[int, _ToolBlockState]
block_types: dict[int, str]
provider_blocks: dict[int, _ProviderBlockState]
next_tool_index: int
stop_reason: str | None
last_usage: dict[str, Any] | None
@@ -109,6 +200,7 @@ class _StreamState:
self.model = model
self.tool_blocks = {}
self.block_types = {}
self.provider_blocks = {}
self.next_tool_index = 0
self.stop_reason = None
self.last_usage = None
@@ -140,13 +232,17 @@ def _handle_block_start(
) -> list[ChatCompletionChunk]:
"""Record the block's type; only ``tool_use`` becomes a local tool call.
``server_tool_use`` (a search the model's backend runs), its
``web_search_tool_result``, and ``thinking`` stream here too — recording
their type keeps the argument fragments that follow from being mistaken for
a local call's arguments (see ``_chunks_for_content_delta``).
A surface-owned block (``server_tool_use``, its ``web_search_tool_result``,
``thinking``) is rebuilt from here on so the round can hand it back (see
``AssistantChatMessage.provider_blocks``); recording its type also keeps the
fragments that follow from being mistaken for a local call's arguments (see
``_chunks_for_content_delta``).
"""
block = event.content_block
state.block_types[event.index] = block.type
if block.type not in _LOCAL_BLOCK_TYPES:
state.provider_blocks[event.index] = _ProviderBlockState(_raw_block_json(block))
return []
if block.type != "tool_use":
return []
call_id = block.id or f"toolu_{event.index}"
@@ -172,6 +268,17 @@ def _handle_message_delta(event: AnthropicMessageDelta, state: _StreamState) ->
state.last_usage = _merge_usage(state.last_usage, usage)
def _chunks_for_block_stop(
event: AnthropicContentBlockStop,
state: _StreamState,
) -> list[ChatCompletionChunk]:
"""Emit a surface-owned block once its fragments are folded in."""
provider = state.provider_blocks.pop(event.index, None)
if provider is None:
return []
return [provider_blocks_chunk(model=state.model, blocks=[provider.finish()])]
def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[ChatCompletionChunk]:
"""Convert one Anthropic stream event into zero or more chat chunks."""
if isinstance(event, AnthropicErrorEvent):
@@ -188,6 +295,9 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
if isinstance(event, AnthropicContentBlockStart):
return _handle_block_start(event, state)
if isinstance(event, AnthropicContentBlockStop):
return _chunks_for_block_stop(event, state)
if isinstance(event, AnthropicContentBlockDelta):
return _chunks_for_content_delta(event, state)
@@ -201,11 +311,31 @@ def _chunks_for_event(event: AnthropicStreamEvent, state: _StreamState) -> list[
return []
def _chunks_for_provider_delta(
delta: AnthropicRawContentBlock,
provider: _ProviderBlockState,
*,
model: str,
) -> list[ChatCompletionChunk]:
"""One fragment of a surface-owned block: its reasoning text, or nothing."""
fragment = provider.add(delta)
if fragment is None:
return []
return [reasoning_delta_chunk(model=model, content=fragment)]
def _chunks_for_content_delta(
event: AnthropicContentBlockDelta,
state: _StreamState,
) -> list[ChatCompletionChunk]:
delta: AnthropicRawContentBlock = event.delta
provider = state.provider_blocks.get(event.index)
if provider is not None:
# A surface-owned block's fragments never become local output here: the
# thinking text streams as reasoning, while the search query and the
# signature only go back with the round.
return _chunks_for_provider_delta(delta, provider, model=state.model)
if delta.type in {"text_delta", "text"} and isinstance(delta.text, str) and delta.text:
return [text_delta_chunk(model=state.model, content=delta.text)]
+27
View File
@@ -79,6 +79,32 @@ def reasoning_delta_chunk(
)
def provider_blocks_chunk(
*,
model: str,
blocks: list[dict[str, Any]],
created: int = 0,
) -> ChatCompletionChunk:
"""Carry provider-opaque blocks a bridge rebuilt mid-stream.
The loop copies them onto the round's assistant message
(``AssistantChatMessage.provider_blocks``), which is what puts them back on
the wire for the surface that produced them.
"""
return ChatCompletionChunk(
id="bridge-stream",
object="chat.completion.chunk",
created=created,
model=model,
choices=[
ChunkChoice(
index=0,
delta=DeltaMessage(provider_blocks=blocks),
)
],
)
def tool_call_delta_chunk(
*,
model: str,
@@ -127,6 +153,7 @@ def usage_chunk(*, model: str, usage: dict[str, Any], created: int = 0) -> ChatC
__all__ = [
"finish_reason_chunk",
"provider_blocks_chunk",
"reasoning_delta_chunk",
"text_delta_chunk",
"tool_call_delta_chunk",
+4 -2
View File
@@ -52,7 +52,8 @@ raise `max_replaces` or narrow `old_string`.
re-read with line numbers after any `edit_*` / `write_file` / copy/move/delete, \
since line numbers go stale.
- `read_file` results start with a 1-based line range `L{begin}-{end}` (`offset` \
is 0-based); `with_lineno` shows per-line numbers instead.
is 0-based); `with_lineno` shows per-line numbers instead. `skill_read` slices a \
skill file the same way.
- `regex_files` takes a Python `re` regex for `pattern`, not a filename glob \
(`*.py` / `**/*.py` fail with `invalid regex`), and a literal file/dir `path` \
(no glob expansion, so `src/**/*.py` fails with `path does not exist`); there is \
@@ -72,7 +73,8 @@ for ordered pipelines (`pipe_out` / `mix_stderr` as needed).
- Prefer `vcs_*` for status/diff/log/branch when enough.
### Network
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell.
- Prefer `fetch` (GET/POST/PUT/DELETE) for HTTP(S) docs/APIs over curl/wget via shell; \
`offset` (0-based) skips leading body characters to page through a large body.
- Set `user_agent` (or a User-Agent header) when the remote expects a specific client; \
otherwise a small default is used. Never rely on shell to spoof identity.
- Private/loopback/LAN hosts need an explicit human policy allow (not skipped by YOLO).
+22 -1
View File
@@ -39,7 +39,11 @@ class AnthropicUserMessage(Struct, tag_field="role", tag="user"):
class AnthropicAssistantMessage(Struct, tag_field="role", tag="assistant"):
content: str | list[AnthropicTextContent | AnthropicToolUseContent]
# ``dict`` entries are provider-opaque blocks echoed back verbatim: a
# ``thinking`` block (its ``signature`` cannot be rebuilt from the text) and
# the ``server_tool_use`` + ``web_search_tool_result`` pair a hosted search
# answers inside the turn.
content: str | list[AnthropicTextContent | AnthropicToolUseContent | dict[str, Any]]
type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
@@ -125,11 +129,13 @@ class AnthropicServerToolUseContent(Struct, omit_defaults=True, tag_field="type"
Not a call for the local registry: the matching
:class:`AnthropicWebSearchToolResultContent` arrives in the same response
(see the ``messages_dispatch`` / ``messages_bridge`` bullets in AGENTS.md).
``caller`` is the surface's own metadata and goes back with the block.
"""
id: str = ""
name: str = ""
input: dict[str, Any] = field(default_factory=dict)
caller: dict[str, Any] | Unset = UNSET
class AnthropicWebSearchToolResultContent(Struct, omit_defaults=True, tag_field="type", tag="web_search_tool_result"):
@@ -166,12 +172,27 @@ class AnthropicMessageResponse(Struct, omit_defaults=True):
class AnthropicRawContentBlock(Struct, omit_defaults=True):
"""One streamed content block: the ``content_block`` / ``delta`` payload.
The fields cover the response union: ``text`` fragments, ``thinking`` and
its opaque ``signature``, a ``server_tool_use``'s ``input`` (streamed as
``partial_json``), and a ``web_search_tool_result``'s ``tool_use_id`` +
``content`` (kept loose — an entry's ``encrypted_content`` goes back
unchanged). ``agent.messages_dispatch`` rebuilds the blocks from these to
hand them to the next request.
"""
type: str = ""
id: str | None = None
name: str | None = None
text: str | None = None
input: dict[str, Any] | None = None
partial_json: str | None = None
thinking: str | None = None
signature: str | None = None
tool_use_id: str | None = None
content: list[dict[str, Any]] | None = None
caller: dict[str, Any] | None = None
class AnthropicMessageStart(Struct, tag_field="type", tag="message_start"):
@@ -5,8 +5,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import msgspec
from msgspec import UNSET
from .model import DeveloperChatMessage, SystemChatMessage
from .model import AssistantChatMessage, DeveloperChatMessage, SystemChatMessage
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -37,9 +38,31 @@ def coerce_developer_messages_to_system(messages: Sequence[AnyChatMessage]) -> l
return out if changed else list(messages)
def coerce_chat_messages(messages: Sequence[AnyChatMessage]) -> list[AnyChatMessage]:
"""Return *messages* as a chat-completions wire can take them.
Both normalizations are for broad provider compatibility: a ``developer``
role becomes ``system`` (see :func:`coerce_developer_messages_to_system`),
and ``AssistantChatMessage.provider_blocks`` is dropped — those blocks
belong to the surface that produced them (Anthropic ``thinking`` and
server-tool blocks; see ``agent.messages_bridge``) and no other provider
knows the key.
"""
coerced = coerce_developer_messages_to_system(messages)
changed = False
out: list[AnyChatMessage] = []
for message in coerced:
if isinstance(message, AssistantChatMessage) and message.provider_blocks is not UNSET:
out.append(msgspec.structs.replace(message, provider_blocks=UNSET))
changed = True
else:
out.append(message)
return out if changed else coerced
def coerce_chat_completions_param(param: ChatCompletionsParam) -> ChatCompletionsParam:
"""Normalize a chat-completions request for broad provider compatibility."""
messages = coerce_developer_messages_to_system(param.messages)
messages = coerce_chat_messages(param.messages)
if messages == param.messages:
return param
return msgspec.structs.replace(param, messages=messages)
@@ -56,7 +79,7 @@ def coerce_chat_completions_param_any(param: Any) -> Any:
if not isinstance(messages_obj, list):
return param
messages = cast("list[AnyChatMessage]", messages_obj)
coerced = coerce_developer_messages_to_system(messages)
coerced = coerce_chat_messages(messages)
if coerced == messages:
return param
return msgspec.structs.replace(param, messages=coerced)
@@ -74,6 +74,12 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
reasoning_content: str | Unset = UNSET
# Provider-opaque assistant blocks kept as raw JSON and echoed back verbatim
# by the surface that produced them (Anthropic: a ``thinking`` block, whose
# ``signature`` only the provider can verify, and the ``server_tool_use`` +
# ``web_search_tool_result`` pair a hosted search answers inside the turn).
# Surfaces that do not know them send the message without the field.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
@@ -243,6 +249,10 @@ class DeltaMessage(Struct):
# ``response.reasoning_text.done``), replacing any streamed fragments.
reasoning_full: bool | Unset = UNSET
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
# Provider-opaque blocks a bridge rebuilt mid-stream (see
# ``AssistantChatMessage.provider_blocks``): the loop copies them onto the
# round's assistant message.
provider_blocks: list[dict[str, Any]] | Unset = UNSET
class ChunkChoice(Struct):
+21 -3
View File
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
# How many unread line numbers to preview in the error message.
_UNREAD_PREVIEW = 8
# Lines of context a suggested read covers on each side of the edit range.
_READ_HINT_MARGIN = 3
def _detect_newline(lines: list[str]) -> str:
for sample in lines:
@@ -68,6 +71,19 @@ def _unread_lines_in_range(
return sorted(set(range(start_line, end_line + 1)) - read_lines)
def _read_hint(path: str, start_line: int, end_line: int, n: int) -> str:
"""A ready-to-copy ``read_file`` call covering the edit range plus a margin.
``read_file`` counts ``offset`` from 0 while ``edit_lineno`` counts lines
from 1, so the call is spelled out instead of asking the model to convert.
The window is clipped to the file (``_validate_range`` already accepted the
edit) and the margin keeps neighbouring lines editable too.
"""
first = max(1, start_line - _READ_HINT_MARGIN)
last = max(first, min(n, end_line + _READ_HINT_MARGIN))
return f"read_file(path={path!r}, offset={first - 1}, limit={last - first + 1}, with_lineno=true)"
def _append_after(target: Path, path: str, text: str, lines: list[str], new_content: str) -> str:
n = len(lines)
newline = _detect_newline(lines[-1:] if lines else [])
@@ -135,8 +151,9 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if read_lines:
window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})"
return (
f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) "
"first — read with a small margin around the lines you plan to edit"
f"error: lines {head} not read{window}; re-read with "
f"{_read_hint(path, start_line, end_line, len(lines))} "
"— offset is 0-based; the window keeps a margin around the edit"
)
recorded_mtime = lineno_read_mtime(str(target))
@@ -148,7 +165,8 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st
if current_mtime is not None and current_mtime != recorded_mtime:
return (
f"error: {path} changed since it was read (line numbers may be stale); "
"re-read with read_file(path, with_lineno=true)"
f"re-read with {_read_hint(path, start_line, end_line, len(lines))} "
"— offset is 0-based"
)
if start_line == len(lines) + 1:
+4
View File
@@ -80,6 +80,10 @@ async def fetch(
"""HTTP request (GET/POST/PUT/DELETE); return status, metadata, and truncated body.
Use for public docs/APIs or (after human policy allow) local/LAN servers.
``offset`` skips that many leading characters of the body (0-based), for
paging through a body larger than ``max_chars``; a body truncated by
``max_chars`` keeps its remainder in memory instead, resumable via its
``truncate_token`` without another request.
``user_agent`` sets User-Agent when provided and takes precedence over a
User-Agent entry in ``headers``. A headers-only User-Agent is kept as-is.
When both omit UA, a small default is used.
+68 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import msgspec
from msgspec import UNSET
from plyngent.agent.messages_bridge import (
@@ -156,15 +157,20 @@ def test_anthropic_response_to_assistant_with_tools() -> None:
assert '"a":1' in call.function.arguments or '"a": 1' in call.function.arguments
def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
"""A server-side search is not a call for the registry, nor part of the message."""
def test_anthropic_response_to_assistant_keeps_thinking_and_search_blocks() -> None:
"""A server-side search is not a registry call, but it is the round's evidence."""
response = AnthropicMessageResponse(
id="msg_search",
model="deepseek-flash",
stop_reason="end_turn",
content=[
AnthropicThinkingContent(thinking="Let me search.", signature="sig-1"),
AnthropicServerToolUseContent(id="call_00_1", name="web_search", input={"query": "DeepSeek-V4"}),
AnthropicServerToolUseContent(
id="call_00_1",
name="web_search",
input={"query": "DeepSeek-V4"},
caller={"type": "direct"},
),
AnthropicWebSearchToolResultContent(
tool_use_id="call_00_1",
content=[{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
@@ -176,6 +182,65 @@ def test_anthropic_response_to_assistant_ignores_server_tool_blocks() -> None:
assistant = anthropic_response_to_assistant(response)
assert assistant.content == "DeepSeek-V4 is out."
assert assistant.tool_calls is UNSET
# The chain of thought is the round's reasoning text; the signature it must
# go back with only exists on the block.
assert assistant.reasoning_content == "Let me search."
assert assistant.provider_blocks == [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "server_tool_use",
"id": "call_00_1",
"name": "web_search",
"input": {"query": "DeepSeek-V4"},
"caller": {"type": "direct"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}],
},
]
def test_chat_param_to_anthropic_param_hands_provider_blocks_back() -> None:
"""The next request carries the round's thinking and search blocks first."""
assistant = AssistantChatMessage(
content="DeepSeek-V4 is out.",
reasoning_content="Let me search.",
provider_blocks=[
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
],
tool_calls=[
AssistantFunctionToolCall(
id="tu_1",
function=AssistantFunctionTool(name="read_file", arguments='{"path": "a.py"}'),
)
],
)
param = ChatCompletionsParam(model="deepseek-flash", messages=[assistant])
built = chat_param_to_anthropic_param(param)
# The wire shape: DeepSeek answers a trimmed ``encrypted_content`` with 422.
data = msgspec.json.decode(msgspec.json.encode(built))
assert data["messages"] == [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me search.", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd918tZXSvVp"}],
},
{"type": "text", "text": "DeepSeek-V4 is out."},
{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": {"path": "a.py"}},
],
}
]
def test_provider_tools_to_anthropic_tools_maps_the_search_intent() -> None:
+87 -17
View File
@@ -8,6 +8,7 @@ from msgspec import UNSET
from plyngent.agent import (
AssistantMessageEvent,
ReasoningDeltaEvent,
TextDeltaEvent,
ToolCallEvent,
ToolRegistry,
@@ -396,9 +397,9 @@ async def test_dispatch_stream_tool_calls_without_stop_yields_no_finish() -> Non
assert not any(c.choices and c.choices[0].finish_reason not in (None, UNSET) for c in chunks)
def _search_stream_events() -> list[AnthropicStreamEvent]:
def _search_stream_events(*, text: bool = True) -> list[AnthropicStreamEvent]:
"""The stream a DeepSeek search turn sends: thinking, search, results, text."""
return [
events: list[AnthropicStreamEvent] = [
AnthropicMessageStart(
message=AnthropicMessageResponse(
id="msg_s",
@@ -407,11 +408,16 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
usage=AnthropicUsage(input_tokens=164, output_tokens=0),
)
),
AnthropicContentBlockStart(index=0, content_block=AnthropicRawContentBlock(type="thinking", text="")),
AnthropicContentBlockStart(
index=0,
content_block=AnthropicRawContentBlock(type="thinking", thinking=""),
),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="thinking_delta", thinking="Search ")),
AnthropicContentBlockDelta(
index=0,
delta=AnthropicRawContentBlock(type="thinking_delta", text="Search."),
delta=AnthropicRawContentBlock(type="thinking_delta", thinking="the docs."),
),
AnthropicContentBlockDelta(index=0, delta=AnthropicRawContentBlock(type="signature_delta", signature="sig-1")),
AnthropicContentBlockStop(index=0),
AnthropicContentBlockStart(
index=1,
@@ -430,23 +436,39 @@ def _search_stream_events() -> list[AnthropicStreamEvent]:
index=2,
content_block=AnthropicRawContentBlock(
type="web_search_tool_result",
id="call_00_1",
input={"content": [{"type": "web_search_result", "url": "https://api-docs.deepseek.com"}]},
tool_use_id="call_00_1",
content=[
{
"type": "web_search_result",
"url": "https://api-docs.deepseek.com",
"encrypted_content": "sd918tZXSvVp",
}
],
),
),
AnthropicContentBlockStop(index=2),
AnthropicContentBlockStart(index=3, content_block=AnthropicRawContentBlock(type="text", text="")),
AnthropicContentBlockDelta(
index=3,
delta=AnthropicRawContentBlock(type="text_delta", text="DeepSeek-V4 is out."),
),
AnthropicContentBlockStop(index=3),
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
),
AnthropicMessageStop(),
]
if text:
events.extend(
[
AnthropicContentBlockStart(index=3, content_block=AnthropicRawContentBlock(type="text", text="")),
AnthropicContentBlockDelta(
index=3,
delta=AnthropicRawContentBlock(type="text_delta", text="DeepSeek-V4 is out."),
),
AnthropicContentBlockStop(index=3),
]
)
events.extend(
[
AnthropicMessageDelta(
delta={"stop_reason": "end_turn"},
usage=AnthropicUsage(input_tokens=0, output_tokens=12),
),
AnthropicMessageStop(),
]
)
return events
async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
@@ -476,10 +498,58 @@ async def test_search_turn_streams_its_text_without_a_local_tool_call() -> None:
assert not any(isinstance(event, ToolCallEvent) for event in events)
assert not any(isinstance(event, ToolResultEvent) for event in events)
assert any(isinstance(event, TextDeltaEvent) and event.content == "DeepSeek-V4 is out." for event in events)
# The chain of thought streams as reasoning, fragment by fragment.
assert [event.content for event in events if isinstance(event, ReasoningDeltaEvent)] == ["Search ", "the docs."]
# ...and the round keeps the search blocks, rebuilt, for the next request.
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
assert assistant.reasoning_content == "Search the docs."
assert assistant.provider_blocks == [
{"type": "thinking", "thinking": "Search the docs.", "signature": "sig-1"},
{
"type": "server_tool_use",
"id": "call_00_1",
"name": "web_search",
"input": {"query": "DeepSeek-V4"},
},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [
{
"type": "web_search_result",
"url": "https://api-docs.deepseek.com",
"encrypted_content": "sd918tZXSvVp",
}
],
},
]
# One round: the search answered inside the response, so nothing was sent back.
assert len(client.calls) == 1
async def test_search_only_round_is_not_an_empty_completion() -> None:
"""A search turn with no text yet still produced payload.
A budget-capped or ``max_uses_exceeded`` search can end the turn on its
blocks alone; calling that an empty completion failed a round the model had
actually used.
"""
client = ScriptedMessagesClient(stream_events=[_search_stream_events(text=False)])
events = [
event
async for event in run_chat_loop(
cast("Any", client),
[UserChatMessage(content="what is DeepSeek-V4?")],
model="deepseek-flash",
stream=True,
)
]
assistant = next(event.message for event in events if isinstance(event, AssistantMessageEvent))
assert assistant.content is None
assert assistant.provider_blocks is not UNSET
assert len(assistant.provider_blocks) == 3
async def test_dispatch_messages_sends_provider_tools_as_server_tools() -> None:
"""Hosted tools reach the Anthropic request in that surface's own shape."""
client = ScriptedMessagesClient(non_stream=[_message_response(text="ok")])
+49
View File
@@ -6,11 +6,14 @@ import pytest
from plyngent.lmproto.anthropic import AnthropicClient
from plyngent.lmproto.anthropic.config import AnthropicConfig
from plyngent.lmproto.anthropic.model import (
AnthropicAssistantMessage,
AnthropicContentBlockDelta,
AnthropicMessageResponse,
AnthropicMessagesParam,
AnthropicMessageStop,
AnthropicRawContentBlock,
AnthropicResponseText,
AnthropicTextContent,
AnthropicThinkingConfig,
AnthropicUsage,
AnthropicUserMessage,
@@ -126,6 +129,52 @@ def test_messages_param_keeps_the_thinking_type_on_the_wire() -> None:
assert data["thinking"] == {"type": "enabled", "budget_tokens": 16384}
def test_stream_block_keeps_the_provider_fields() -> None:
"""A streamed block carries what a rebuild puts back on the wire."""
result = msgspec.json.decode(
b'{"type":"web_search_tool_result","tool_use_id":"call_00_1",'
b'"content":[{"type":"web_search_result","encrypted_content":"sd9"}]}',
type=AnthropicRawContentBlock,
)
assert result.tool_use_id == "call_00_1"
assert result.content == [{"type": "web_search_result", "encrypted_content": "sd9"}]
thinking = msgspec.json.decode(
b'{"type":"thinking","thinking":"hmm","signature":"sig-1"}',
type=AnthropicRawContentBlock,
)
assert (thinking.thinking, thinking.signature) == ("hmm", "sig-1")
def test_assistant_message_encodes_provider_blocks_verbatim() -> None:
"""An echoed block keeps its own keys — ``encrypted_content`` included."""
param = AnthropicMessagesParam(
model="deepseek-flash",
messages=[
AnthropicAssistantMessage(
content=[
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
AnthropicTextContent(text="done"),
]
)
],
)
data = msgspec.json.decode(msgspec.json.encode(param))
assert data["messages"][0]["content"] == [
{"type": "thinking", "thinking": "hmm", "signature": "sig-1"},
{
"type": "web_search_tool_result",
"tool_use_id": "call_00_1",
"content": [{"type": "web_search_result", "encrypted_content": "sd9"}],
},
{"type": "text", "text": "done"},
]
async def test_client_messages_create(monkeypatch: pytest.MonkeyPatch) -> None:
client = AnthropicClient(AnthropicConfig(api_key="sk-test", base_url="https://example/v1"))
+31
View File
@@ -5,11 +5,13 @@ from __future__ import annotations
from collections.abc import Sequence
import msgspec
from msgspec import UNSET
from plyngent.lmproto.deepseek.openai_compat.model import ChatCompletionsParam as DeepseekChatCompletionsParam
from plyngent.lmproto.openai_compatible.compat import (
coerce_chat_completions_param,
coerce_chat_completions_param_any,
coerce_chat_messages,
coerce_developer_messages_to_system,
developer_to_system_message,
)
@@ -88,3 +90,32 @@ def test_generic_coercion_handles_deepseek_runtime_param() -> None:
coerced = coerce_chat_completions_param_any(base)
raw = msgspec.json.decode(msgspec.json.encode(coerced))
assert raw["messages"][0]["role"] == "system"
def test_coerce_chat_messages_drops_provider_blocks() -> None:
"""Another surface's opaque blocks are not a chat-completions key."""
messages: list[AnyChatMessage] = [
UserChatMessage(content="hi"),
AssistantChatMessage(
content="done",
reasoning_content="hmm",
provider_blocks=[{"type": "thinking", "thinking": "hmm", "signature": "sig-1"}],
),
]
coerced = coerce_chat_messages(messages)
assistant = coerced[1]
assert isinstance(assistant, AssistantChatMessage)
assert assistant.provider_blocks is UNSET
# The reasoning slot is the provider-neutral one and stays.
assert assistant.reasoning_content == "hmm"
raw = msgspec.json.decode(msgspec.json.encode(ChatCompletionsParam(model="m", messages=coerced)))
assert "provider_blocks" not in raw["messages"][1]
# The history the copy came from keeps them for the surface that made them.
original = messages[1]
assert isinstance(original, AssistantChatMessage)
assert original.provider_blocks is not UNSET
def test_coerce_chat_messages_leaves_plain_history_alone() -> None:
messages: list[AnyChatMessage] = [UserChatMessage(content="hi"), AssistantChatMessage(content="done")]
assert coerce_chat_messages(messages) == messages
+20
View File
@@ -49,6 +49,24 @@ def test_edit_lineno_error_shows_read_window(workspace: object) -> None:
assert "not read" in out
assert "read lines 2-3" in out
assert "margin" in out
# Ready to copy: 1-based line 1 is offset 0, and n=4 clamps the limit.
assert "read_file(path='j.txt', offset=0, limit=4, with_lineno=true)" in out
def test_edit_lineno_error_suggests_the_read_file_call(workspace: object) -> None:
"""Both rejections hand back a ready-to-copy call with a 0-based ``offset``."""
del workspace
_ = call_sync(write_file, "s.txt", "".join(f"line {i}\n" for i in range(1, 21)))
_ = reset_lineno_tracker()
_ = call_sync(read_file, "s.txt", offset=0, limit=10, with_lineno=True) # lines 1-10
# Line 15 was never shown; the hint reads it back with a margin (line 12 → offset 11).
out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n")
assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out
# A stale read gets a fresh call for the same range after an external write.
_ = call_sync(write_file, "s.txt", "changed\n")
out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n")
assert "changed since it was read" in out
assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
@@ -84,6 +102,8 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
_ = call_sync(read_file, "ap.txt", offset=0, limit=2, with_lineno=True)
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
assert "not read" in out and "3" in out
# Appending needs the last line, so the suggested window ends at n.
assert "read_file(path='ap.txt', offset=0, limit=3, with_lineno=true)" in out
# Read the last line, then append succeeds.
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")