feat(backends,conversations,frontends): opus 5.5 progress updates reach telegram, current claude cli in the image
This commit is contained in:
1 parent
e58153628c
commit
bbce425eeb
9 files changed
+140
-20
No files matched your search
+21
-2
@@ -45,11 +45,30 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
|
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
|
||||||
# the user's config. The claude CLI itself ships inside the
|
# the user's config.
|
||||||
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
|
|
||||||
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
|
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
|
||||||
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
|
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
|
||||||
|
|
||||||
|
# The claude-agent-sdk wheel bundles a CLI, but it trails the npm release:
|
||||||
|
# 0.2.157 carries 2.1.277, which does not know claude-opus-5-5 and writes an
|
||||||
|
# [claude-code:unrecognized_model] warning per turn. Install the npm build and
|
||||||
|
# point BEAVER_CLAUDE_CLI at it; the adapter prefers it over the bundled one.
|
||||||
|
#
|
||||||
|
# Only the glibc platform package is pulled: the meta package also drags in the
|
||||||
|
# musl build, and this image is glibc for the reason stated at the top. The
|
||||||
|
# tree lands under /opt world-readable because the model runs as beaver-runner,
|
||||||
|
# not as root.
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1.280
|
||||||
|
RUN mkdir -p /opt/claude-code \
|
||||||
|
&& cd /opt/claude-code \
|
||||||
|
&& echo '{"name":"claude-cli-holder","private":true}' > package.json \
|
||||||
|
&& bun add @anthropic-ai/claude-code-linux-x64@${CLAUDE_CODE_VERSION} \
|
||||||
|
&& ln -sf /opt/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude \
|
||||||
|
/usr/local/bin/claude-cli \
|
||||||
|
&& chmod -R a+rX /opt/claude-code \
|
||||||
|
&& /usr/local/bin/claude-cli --version
|
||||||
|
ENV BEAVER_CLAUDE_CLI=/usr/local/bin/claude-cli
|
||||||
|
|
||||||
# The model process runs as `beaver-runner`, not as the gateway: the
|
# The model process runs as `beaver-runner`, not as the gateway: the
|
||||||
# adapter spawns claude under this uid with a whitelisted environment.
|
# adapter spawns claude under this uid with a whitelisted environment.
|
||||||
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
|
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ dependencies = [
|
|||||||
"anthropic>=0.103.0",
|
"anthropic>=0.103.0",
|
||||||
"anyio>=4.13.0",
|
"anyio>=4.13.0",
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"claude-agent-sdk>=0.2.150",
|
"claude-agent-sdk>=0.2.157",
|
||||||
"croniter>=6.2.4",
|
"croniter>=6.2.4",
|
||||||
"fastapi>=0.136.1",
|
"fastapi>=0.136.1",
|
||||||
"fastmcp>=3.3.1",
|
"fastmcp>=3.3.1",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class ClaudeOptions(BaseModel):
|
|||||||
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||||
|
|
||||||
effort: str | None = None
|
effort: str | None = None
|
||||||
|
thinking: Mapping[str, str] | None = None
|
||||||
|
"""Thinking config for the SDK; ``None`` keeps the CLI default."""
|
||||||
|
|
||||||
include_partial_messages: bool = True
|
include_partial_messages: bool = True
|
||||||
"""Token-level deltas on the wire; off means one delta per finished block."""
|
"""Token-level deltas on the wire; off means one delta per finished block."""
|
||||||
|
|
||||||
|
|||||||
@@ -726,6 +726,7 @@ class ClaudeSdkBackend:
|
|||||||
return ClaudeAgentOptions(
|
return ClaudeAgentOptions(
|
||||||
model=agent.model or None,
|
model=agent.model or None,
|
||||||
effort=cast("Any", opt.effort),
|
effort=cast("Any", opt.effort),
|
||||||
|
thinking=cast("Any", dict(opt.thinking) if opt.thinking else None),
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
setting_sources=[],
|
setting_sources=[],
|
||||||
strict_mcp_config=True,
|
strict_mcp_config=True,
|
||||||
@@ -933,7 +934,17 @@ async def _disconnect(live: Session) -> None:
|
|||||||
_log.exception("disconnect failed for session %s", live.session_id)
|
_log.exception("disconnect failed for session %s", live.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
CLI_ENV = "BEAVER_CLAUDE_CLI"
|
||||||
|
"""Path to a ``claude`` CLI that wins over the one bundled in the wheel."""
|
||||||
|
|
||||||
|
|
||||||
def _claude_binary() -> str:
|
def _claude_binary() -> str:
|
||||||
|
override = os.environ.get(CLI_ENV)
|
||||||
|
if override:
|
||||||
|
if not Path(override).is_file():
|
||||||
|
msg = f"{CLI_ENV} points at {override!r}, which is not a file"
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
return override
|
||||||
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
|
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
|
||||||
if bundled.is_file():
|
if bundled.is_file():
|
||||||
return str(bundled)
|
return str(bundled)
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ from beaver_gateway.frontends.accumulate import StreamAccumulator
|
|||||||
from beaver_gateway.storage.models import Conversation, InjectQueueItem, RateLimit
|
from beaver_gateway.storage.models import Conversation, InjectQueueItem, RateLimit
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import AsyncIterator, Callable, Sequence
|
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||||
|
|
||||||
from beaver_gateway.events.stream import MessageStreamEvent
|
from beaver_gateway.events.stream import MessageStreamEvent
|
||||||
|
|
||||||
__all__ = ["Turns", "content_of", "image_block"]
|
__all__ = ["Turns", "content_of", "image_block", "reply_text"]
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -164,12 +164,7 @@ class Turns(Seeds):
|
|||||||
):
|
):
|
||||||
acc.feed(event)
|
acc.feed(event)
|
||||||
message = acc.finalize(model=agent.model)
|
message = acc.finalize(model=agent.model)
|
||||||
reply = "\n\n".join(
|
return reply_text(message.content), capture
|
||||||
getattr(b, "text", "")
|
|
||||||
for b in message.content
|
|
||||||
if getattr(b, "type", "") == "text"
|
|
||||||
).strip()
|
|
||||||
return reply, capture
|
|
||||||
|
|
||||||
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
|
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
|
||||||
return None
|
return None
|
||||||
@@ -489,6 +484,22 @@ class Turns(Seeds):
|
|||||||
IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
IMAGE_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def reply_text(content: Iterable[Any]) -> str:
|
||||||
|
"""Text blocks plus non-empty thinking blocks, in block order.
|
||||||
|
|
||||||
|
What the model writes between tool calls rides in thinking blocks; the
|
||||||
|
hidden reasoning in them is empty and drops out on its own.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in content:
|
||||||
|
kind = getattr(block, "type", "")
|
||||||
|
if kind == "text":
|
||||||
|
parts.append(getattr(block, "text", "") or "")
|
||||||
|
elif kind == "thinking":
|
||||||
|
parts.append(getattr(block, "thinking", "") or "")
|
||||||
|
return "\n\n".join(p for p in parts if p).strip()
|
||||||
|
|
||||||
|
|
||||||
def content_of(
|
def content_of(
|
||||||
text: str, attachments: Sequence[dict[str, Any]] | None
|
text: str, attachments: Sequence[dict[str, Any]] | None
|
||||||
) -> str | list[dict[str, Any]]:
|
) -> str | list[dict[str, Any]]:
|
||||||
|
|||||||
@@ -983,7 +983,12 @@ class TelegramFrontend(Frontend):
|
|||||||
draft.set_status(self.texts.writing)
|
draft.set_status(self.texts.writing)
|
||||||
draft.append(str(delta.get("text") or ""))
|
draft.append(str(delta.get("text") or ""))
|
||||||
elif delta.get("type") == "thinking_delta":
|
elif delta.get("type") == "thinking_delta":
|
||||||
draft.set_status(self.texts.thinking)
|
progress = str(delta.get("thinking") or "")
|
||||||
|
if progress:
|
||||||
|
draft.set_status(self.texts.writing)
|
||||||
|
draft.append(progress)
|
||||||
|
else:
|
||||||
|
draft.set_status(self.texts.thinking)
|
||||||
elif kind == "content_block_start":
|
elif kind == "content_block_start":
|
||||||
block = raw.get("content_block") or {}
|
block = raw.get("content_block") or {}
|
||||||
if block.get("type") == "tool_use":
|
if block.get("type") == "tool_use":
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from claude_agent_sdk import (
|
|||||||
project_key_for_directory,
|
project_key_for_directory,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from anthropic.types import TextBlock as AnthropicTextBlock
|
||||||
|
from anthropic.types import ThinkingBlock
|
||||||
|
|
||||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||||
|
from beaver_gateway.conversations.turns import reply_text
|
||||||
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
|
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
|
||||||
from beaver_gateway.events.bus import EventBus
|
from beaver_gateway.events.bus import EventBus
|
||||||
from beaver_gateway.conversations.service import (
|
from beaver_gateway.conversations.service import (
|
||||||
@@ -958,3 +962,17 @@ async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> No
|
|||||||
assert client.prompts[-1].startswith("two")
|
assert client.prompts[-1].startswith("two")
|
||||||
rows = await world.conversations.queue.recent(conv.id)
|
rows = await world.conversations.queue.recent(conv.id)
|
||||||
assert [r.status for r in rows] == ["done", "done"]
|
assert [r.status for r in rows] == ["done", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_text_keeps_progress_updates_and_drops_hidden_reasoning() -> None:
|
||||||
|
blocks = [
|
||||||
|
ThinkingBlock(type="thinking", thinking="", signature="s"),
|
||||||
|
ThinkingBlock(type="thinking", thinking="looking it up", signature="s"),
|
||||||
|
AnthropicTextBlock(type="text", text="found it"),
|
||||||
|
]
|
||||||
|
assert reply_text(blocks) == "looking it up\n\nfound it"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reply_text_on_a_plain_answer_is_unchanged() -> None:
|
||||||
|
blocks = [AnthropicTextBlock(type="text", text=" just an answer ")]
|
||||||
|
assert reply_text(blocks) == "just an answer"
|
||||||
@@ -20,6 +20,7 @@ from beaver_gateway.frontends.base import GatewayRuntime
|
|||||||
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
|
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
|
||||||
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||||
|
from beaver_gateway.frontends.telegram.texts import TelegramTexts
|
||||||
from beaver_gateway.frontends.telegram.render import (
|
from beaver_gateway.frontends.telegram.render import (
|
||||||
LIMIT,
|
LIMIT,
|
||||||
chunks,
|
chunks,
|
||||||
@@ -1194,3 +1195,56 @@ async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
|
|||||||
outside = await _send(stack, master, path="/etc/passwd")
|
outside = await _send(stack, master, path="/etc/passwd")
|
||||||
assert outside["is_error"]
|
assert outside["is_error"]
|
||||||
assert "outside the allowed roots" in outside["content"][0]["text"]
|
assert "outside the allowed roots" in outside["content"][0]["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_draft() -> Draft:
|
||||||
|
return Draft(cast("Any", None), chat_id=USER, thread_id=None, turn_id="t")
|
||||||
|
|
||||||
|
|
||||||
|
def _thinking_delta(text: str) -> Any:
|
||||||
|
return {
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"type": "thinking_delta", "thinking": text},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> None:
|
||||||
|
draft = _progress_draft()
|
||||||
|
texts = TelegramTexts()
|
||||||
|
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
|
||||||
|
feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k")
|
||||||
|
|
||||||
|
feed(_thinking_delta(""))
|
||||||
|
assert draft.text == ""
|
||||||
|
assert draft.status == texts.thinking
|
||||||
|
|
||||||
|
feed(_thinking_delta("checking the vault"))
|
||||||
|
assert draft.text == "checking the vault"
|
||||||
|
assert draft.status == texts.writing
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
|
||||||
|
draft = _progress_draft()
|
||||||
|
texts = TelegramTexts()
|
||||||
|
stub = SimpleNamespace(
|
||||||
|
_drafts={"k": draft},
|
||||||
|
texts=texts,
|
||||||
|
_tool_status=lambda name, _input: texts.tool.format(label=name),
|
||||||
|
)
|
||||||
|
feed = functools.partial(TelegramFrontend._feed_draft, cast("Any", stub), "k")
|
||||||
|
|
||||||
|
feed(_thinking_delta("reading the note"))
|
||||||
|
feed(
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": 1,
|
||||||
|
"content_block": {"type": "tool_use", "name": "Read"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
feed(_thinking_delta("now writing it back"))
|
||||||
|
assert draft.text == "reading the note\n\nnow writing it back"
|
||||||
@@ -338,7 +338,7 @@ requires-dist = [
|
|||||||
{ name = "anthropic", specifier = ">=0.103.0" },
|
{ name = "anthropic", specifier = ">=0.103.0" },
|
||||||
{ name = "anyio", specifier = ">=4.13.0" },
|
{ name = "anyio", specifier = ">=4.13.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "claude-agent-sdk", specifier = ">=0.2.150" },
|
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
|
||||||
{ name = "croniter", specifier = ">=6.2.4" },
|
{ name = "croniter", specifier = ">=6.2.4" },
|
||||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||||
{ name = "fastmcp", specifier = ">=3.3.1" },
|
{ name = "fastmcp", specifier = ">=3.3.1" },
|
||||||
@@ -450,7 +450,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-agent-sdk"
|
name = "claude-agent-sdk"
|
||||||
version = "0.2.150"
|
version = "0.2.157"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -458,13 +458,12 @@ dependencies = [
|
|||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "sniffio" },
|
{ name = "sniffio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user