27 Commits

Author SHA1 Message Date
c3d84b904a feat: implement channel runtime connectors 2026-06-03 16:22:44 +08:00
ee972441f5 docs: harden external connector implementation plans 2026-06-03 10:32:50 +08:00
d335199a64 docs: add external connector implementation plans 2026-06-03 09:24:06 +08:00
feeaccc0e3 docs: tighten external connector contract 2026-06-03 09:12:30 +08:00
cf35edb4ca docs: decouple external connector sidecar design 2026-06-02 18:05:46 +08:00
e0a4862af8 docs: add openclaw sidecar connector design 2026-06-02 17:59:57 +08:00
c3a0aef104 docs: tighten channel connector API plan 2026-06-02 16:07:01 +08:00
b25713a141 docs: refine channel connectors foundation plan 2026-06-02 15:51:16 +08:00
d74a1c9c12 docs: add channel connectors foundation plan 2026-06-02 15:37:36 +08:00
834d4e1e2f docs: add channel connector pairing design 2026-06-02 15:17:46 +08:00
6a6ddc21c0 docs: design terminal websocket channel 2026-06-01 16:41:19 +08:00
826db8ec2e ```
feat(llm): 添加 Hermes Gateway LLM 设计文档
```
2026-06-01 16:05:15 +08:00
33a9845566 ```
feat(engine): 添加技能查看工具并优化异步任务管理

- 添加SkillViewTool到引擎加载器中,增强技能管理功能
- 在AgentLoop中引入_active_direct_task来跟踪活跃任务
- 实现直接任务执行时的同步处理逻辑
- 更新工具实例化方式以支持依赖注入

feat(config): 增加智能体运行时参数配置支持

- 扩展AgentDefaultsConfig添加max_tokens和temperature字段
- 实现配置解析函数_first_config_value处理多个配置源
- 支持通过Web API动态更新智能体运行时参数
- 添加前端页面配置表单和验证逻辑

refactor(provider): 统一最大令牌数参数类型为可选整型

- 将所有LLM提供者的max_tokens参数改为int | None类型
- 为AnthropicProvider实现模型特定的最大令牌数默认值
- 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值
- 移除硬编码的默认值,改用条件判断

feat(process): 增强事件投影功能

- 添加工具调用开始/结束事件的映射逻辑
- 实现技能激活事件的识别和展示
- 添加辅助函数处理工具调用名称和参数提取
- 优化运行记录关联逻辑,提升事件匹配准确性

fix(web): 更新网络请求客户端信任环境设置

- 将WebFetchTool和WebSearchTool的trust_env参数设为True
- 确保HTTP客户端能够正确使用系统代理配置
- 修复可能的网络连接问题

test: 添加配置加载和事件投影相关测试

- 新增智能体默认参数配置测试用例
- 实现API配置持久化和重载测试
- 添加技能卡片和工具事件的投影测试
```
2026-05-27 13:37:06 +08:00
55b39563a0 test: cover task detail live timeline updates 2026-05-26 13:49:31 +08:00
41ac87e322 feat: make task detail timeline first 2026-05-26 13:48:18 +08:00
542b23ef6e fix: tighten task detail component interactions 2026-05-26 12:48:27 +08:00
9002d1206f feat: add task detail timeline components 2026-05-26 12:30:22 +08:00
dd9f40b38c fix: allow user task timeline actor 2026-05-26 12:24:50 +08:00
96562877cc fix: align agent timeline event contract 2026-05-26 12:20:33 +08:00
f58a57e5b8 test: cover failed task team timeline projection 2026-05-26 12:13:20 +08:00
362aae9b12 feat: enrich task process timeline events 2026-05-26 12:09:57 +08:00
29d175222d fix: make acceptance timeline dedupe robust 2026-05-26 12:04:12 +08:00
2e4f8541ee fix: dedupe task timeline milestones 2026-05-26 11:59:49 +08:00
a1164dc49a fix: harden task timeline model 2026-05-26 11:52:46 +08:00
7b638b083a feat: add task timeline model 2026-05-26 11:28:57 +08:00
6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00
16347caf5e Add task detail live execution design 2026-05-26 10:55:16 +08:00
214 changed files with 35230 additions and 2498 deletions

View File

@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
# Must be reachable from auth-portal and authz-service containers.
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
# External connector sidecar
EXTERNAL_CONNECTOR_TOKEN=
BEAVER_BRIDGE_TOKEN=
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
EXTERNAL_CONNECTOR_PORT=8787
CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787
# fake | vendor_cli | weixin_ilink
CONNECTOR_PROVIDER=vendor_cli
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
WEIXIN_CONNECT_COMMAND=
WEIXIN_STATUS_COMMAND=
WEIXIN_SEND_COMMAND=
FEISHU_CONNECT_COMMAND=
FEISHU_STATUS_COMMAND=
FEISHU_SEND_COMMAND=

1
.gitignore vendored
View File

@ -21,6 +21,7 @@ sessions/
**/.ruff_cache/
**/.mypy_cache/
**/.cache/
**/.codegraph/
**/.venv/
**/dist/
**/build/

View File

@ -0,0 +1,458 @@
# Beaver Terminal WebSocket Integration Guide
Date: 2026-06-01
Audience: the small-terminal-side Codex agent that will modify terminal firmware or terminal app code.
## Goal
Connect the small terminal device to Beaver through a text-only WebSocket channel.
The first acceptance target is simple:
1. The terminal opens a WebSocket connection to Beaver.
2. The terminal sends a `connect` frame with a stable `peer_id`.
3. The terminal sends one text `message` frame.
4. The terminal receives an `ack`.
5. The terminal receives the final assistant text response from Beaver.
6. The terminal can reconnect with the same `peer_id` and keep the same Beaver session.
This document replaces the earlier Hermes LiveKit LLM adapter design for the terminal-side work. Do not implement a LiveKit LLM adapter from this document.
## Non-Goals
- Do not implement audio streaming.
- Do not implement camera, screen, image, or multimodal frames.
- Do not implement token streaming.
- Do not implement terminal-side tools.
- Do not implement AuthZ, device registration, OAuth, or pairing in the first pass.
- Do not call Beaver REST chat endpoints or the existing Web UI `/ws/{session_id}` endpoint.
- Do not build an OpenAI-compatible proxy.
- Do not implement Hermes Agent or LiveKit changes on the terminal side.
## Beaver Endpoint
The terminal connects to:
```text
ws://<beaver-host>/api/channels/<channel_id>/ws
```
For local development through the Beaver app instance nginx port:
```text
ws://127.0.0.1:8080/api/channels/terminal-dev/ws
```
For direct backend development without nginx:
```text
ws://127.0.0.1:18080/api/channels/terminal-dev/ws
```
Use `wss://` when Beaver is deployed behind TLS.
The expected first channel id is:
```text
terminal-dev
```
The terminal implementation should make the URL configurable, for example:
```text
BEAVER_WS_URL=ws://127.0.0.1:8080/api/channels/terminal-dev/ws
TERMINAL_PEER_ID=device-001
TERMINAL_DEVICE_NAME=desk-terminal
```
## Protocol Overview
The transport is JSON over WebSocket.
All frames are UTF-8 JSON objects. The terminal should ignore unknown fields. Beaver will ignore unknown fields unless the frame type is invalid.
The protocol is request/reply oriented in this phase. Beaver sends only final assistant messages, not token deltas.
Required frame flow:
```text
terminal -> Beaver: connect
Beaver -> terminal: connected
terminal -> Beaver: message
Beaver -> terminal: ack
Beaver -> terminal: message
```
Optional heartbeat:
```text
terminal -> Beaver: ping
Beaver -> terminal: pong
```
## Connect Frame
The terminal must send `connect` immediately after the WebSocket opens.
Terminal to Beaver:
```json
{
"type": "connect",
"peer_id": "device-001",
"device_name": "desk-terminal",
"capabilities": ["text"]
}
```
Required fields:
- `type`: must be `"connect"`.
- `peer_id`: stable terminal identity. Reuse this value across reconnects.
Recommended fields:
- `device_name`: human-readable terminal name.
- `capabilities`: include `"text"`.
Optional fields:
- `thread_id`: optional sub-session key. Omit it for the first pass.
- `user_id`: optional user identity. Omit it unless the terminal already has a stable user id.
Beaver to terminal:
```json
{
"type": "connected",
"channel_id": "terminal-dev",
"session_id": "terminal-dev:local:device-001"
}
```
The terminal should store `session_id` for logging and diagnostics. It does not need to send `session_id` back in message frames.
## Message Frame
Terminal to Beaver:
```json
{
"type": "message",
"message_id": "m-001",
"text": "hello"
}
```
Required fields:
- `type`: must be `"message"`.
- `message_id`: unique id for this user message.
- `text`: non-empty user text.
Recommended `message_id` format:
```text
<peer_id>-<monotonic-counter>
```
Example:
```text
device-001-000001
device-001-000002
```
The terminal should persist the counter if practical. If persistence is unavailable, generate a UUID or timestamp-based id. Reusing the same `message_id` tells Beaver to treat the frame as a duplicate.
Optional fields:
- `thread_id`: use only when the terminal intentionally wants a separate Beaver session.
- `user_id`: use only when the terminal has a stable user id.
## Ack Frame
Beaver sends an ack after accepting or deduplicating the inbound message.
Accepted:
```json
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": true
}
```
Duplicate still processing:
```json
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": false,
"duplicate": true,
"pending": true
}
```
Duplicate already completed:
```json
{
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": false,
"duplicate": true,
"pending": false,
"reply": "cached assistant reply"
}
```
Terminal behavior:
- If `accepted` is true, wait for the assistant `message`.
- If `duplicate` and `reply` is present, display the cached reply.
- If `duplicate` and `pending` is true, keep waiting on the socket.
- If `error` is present, display or log the error.
## Assistant Message Frame
Beaver to terminal:
```json
{
"type": "message",
"role": "assistant",
"message_id": "device-001-000001",
"run_id": "run-id",
"text": "assistant reply",
"finish_reason": "stop"
}
```
Fields:
- `type`: `"message"`.
- `role`: `"assistant"`.
- `message_id`: the user message id this response belongs to.
- `run_id`: Beaver run id for diagnostics.
- `text`: final assistant response.
- `finish_reason`: usually `"stop"`, or `"error"` when the run failed.
Terminal behavior:
- Render or speak `text`.
- Treat `finish_reason == "error"` as a failed turn.
- Do not expect token-level streaming in this phase.
## Ping And Pong
Terminal to Beaver:
```json
{"type": "ping"}
```
Beaver to terminal:
```json
{"type": "pong"}
```
Recommended heartbeat interval:
```text
30 seconds
```
If no pong or other frame is received after a reasonable timeout, reconnect.
## Error Frame
Beaver may send:
```json
{
"type": "error",
"error": "human readable error"
}
```
Terminal behavior:
- Log the error.
- Keep the connection open unless the WebSocket closes.
- If the error is for a user message, allow the user to retry with a new `message_id`.
Common first-pass errors:
- `connect` is required before `message`.
- `peer_id` is required.
- `message_id` is required.
- `text` is required.
- Unsupported websocket frame type.
## Terminal State Machine
Implement the terminal client as a small state machine.
```text
DISCONNECTED
-> connect websocket
CONNECTING
-> websocket open, send connect frame
WAIT_CONNECTED
-> receive connected
READY
-> send message frame
WAIT_ACK
-> receive ack
WAIT_REPLY
-> receive assistant message
READY
```
On WebSocket close or network failure, transition to `DISCONNECTED` and reconnect with backoff.
Recommended reconnect policy:
- Start at 1 second.
- Double up to 30 seconds.
- Reset backoff after a successful `connected` frame.
On reconnect, use the same `peer_id`.
## Terminal Implementation Requirements
The terminal-side code should provide:
- A configurable Beaver WebSocket URL.
- A stable `peer_id`.
- A configurable `device_name`.
- A monotonic or otherwise unique `message_id` generator.
- JSON encoding and decoding.
- Connect frame on socket open.
- Ping/pong heartbeat.
- Reconnect with backoff.
- A queue or guard so only one user text turn is in flight at a time for the first pass.
- Logging for `session_id`, `message_id`, `run_id`, and errors.
The terminal-side code does not need:
- Multi-room session logic.
- Hermes session management.
- LiveKit `AgentSession`.
- Audio chunking.
- Tool calls.
- OAuth or token refresh.
## Example Client Pseudocode
```python
peer_id = load_or_create_peer_id()
counter = load_counter()
async def run_terminal_client():
while True:
try:
async with connect(BEAVER_WS_URL) as ws:
await ws.send_json({
"type": "connect",
"peer_id": peer_id,
"device_name": DEVICE_NAME,
"capabilities": ["text"],
})
connected = await ws.receive_json()
assert connected["type"] == "connected"
log("session_id", connected["session_id"])
await read_send_receive_loop(ws)
except Exception as exc:
log("websocket disconnected", exc)
await sleep(next_backoff())
async def send_user_text(ws, text):
global counter
counter += 1
save_counter(counter)
message_id = f"{peer_id}-{counter:06d}"
await ws.send_json({
"type": "message",
"message_id": message_id,
"text": text,
})
while True:
frame = await ws.receive_json()
if frame["type"] == "ack" and frame.get("message_id") == message_id:
if frame.get("reply"):
return frame["reply"]
continue
if frame["type"] == "message" and frame.get("role") == "assistant":
if frame.get("message_id") == message_id:
return frame.get("text", "")
if frame["type"] == "error":
raise RuntimeError(frame.get("error", "unknown error"))
```
Adapt the pseudocode to the terminal runtime language and WebSocket library.
## Manual Test With websocat
If `websocat` is available, a developer can manually test the protocol:
```bash
websocat ws://127.0.0.1:8080/api/channels/terminal-dev/ws
```
Then paste:
```json
{"type":"connect","peer_id":"device-001","device_name":"desk-terminal","capabilities":["text"]}
```
Expected response:
```json
{"type":"connected","channel_id":"terminal-dev","session_id":"terminal-dev:local:device-001"}
```
Then paste:
```json
{"type":"message","message_id":"device-001-000001","text":"hello"}
```
Expected responses:
```json
{"type":"ack","message_id":"device-001-000001","session_id":"terminal-dev:local:device-001","accepted":true}
```
Then, after Beaver finishes the run:
```json
{"type":"message","role":"assistant","message_id":"device-001-000001","run_id":"...","text":"...","finish_reason":"stop"}
```
## Acceptance Checklist For Terminal-Side Codex
- The terminal opens the configured Beaver WebSocket URL.
- The terminal sends `connect` immediately after open.
- The terminal receives and logs `connected.session_id`.
- The terminal sends text using a unique `message_id`.
- The terminal receives `ack`.
- The terminal receives and displays assistant `message.text`.
- The terminal handles `ping`/`pong`.
- The terminal reconnects with the same `peer_id`.
- The terminal does not use REST chat or `/ws/{session_id}`.
- The terminal implementation remains text-only for the first pass.
When this checklist passes against Beaver, the first-stage device integration is accepted from the terminal side.

View File

@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
ARG NPM_FETCH_RETRIES="5"
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
ARG APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/debian"
ARG PYPI_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
RUN apt-get update && \
RUN find /etc/apt -type f \( -name "*.list" -o -name "*.sources" \) -exec \
sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR}-security|g; s|http://deb.debian.org/debian|${APT_MIRROR}|g; s|http://security.debian.org/debian-security|${APT_MIRROR}-security|g" {} + && \
apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
@ -63,7 +67,7 @@ WORKDIR /opt/app/backend
COPY backend/pyproject.toml backend/README.md ./
COPY backend/beaver/ ./beaver/
RUN uv pip install --system --no-cache .
RUN uv pip install --system --no-cache --index-url "${PYPI_INDEX_URL}" ".[channels]"
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /build/frontend/next.config.js ./

View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-27T05:25:11.756341+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756349+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-27T05:25:11.756351+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756353+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-27T05:25:11.756355+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756356+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-27T05:25:11.756358+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756358+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-27T05:25:11.756360+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756360+00:00"
}
],
"version": 1
}

View File

@ -4,6 +4,7 @@ from .builder import (
ContextBuildInput,
ContextBuildResult,
ContextBuilder,
RuntimeContext,
SessionContext,
SkillContext,
)
@ -12,6 +13,7 @@ __all__ = [
"ContextBuildInput",
"ContextBuildResult",
"ContextBuilder",
"RuntimeContext",
"SessionContext",
"SkillContext",
]

View File

@ -76,10 +76,25 @@ class SessionContext:
model: str | None = None
user_id: str | None = None
channel: str | None = None
channel_kind: str | None = None
account_id: str | None = None
peer_id: str | None = None
peer_type: str | None = None
chat_id: str | None = None
thread_id: str | None = None
parent_session_id: str | None = None
@dataclass(slots=True)
class RuntimeContext:
"""Per-run runtime facts that should be visible to the model."""
utc_datetime: str
local_datetime: str
timezone: str | None = None
utc_offset: str | None = None
@dataclass(slots=True)
class ContextBuildInput:
"""一次上下文构建所需的全部输入。
@ -103,6 +118,7 @@ class ContextBuildInput:
memory_snapshot: MemorySnapshot | None = None
activated_skills: list[SkillContext] = field(default_factory=list)
session_context: SessionContext | None = None
runtime_context: RuntimeContext | None = None
execution_context: str | None = None
extra_sections: list[str] = field(default_factory=list)
@ -143,9 +159,10 @@ class ContextBuilder:
1. Beaver user-facing assistant identity
2. base system prompt
3. session metadata
4. execution context
5. frozen memory snapshot
6. extra sections
4. runtime date/time
5. execution context
6. frozen memory snapshot
7. extra sections
这样设计的原因:
- 身份与总规则要最靠前
@ -164,6 +181,10 @@ class ContextBuilder:
if session_section:
sections.append(session_section)
runtime_section = self._render_runtime_section(build_input.runtime_context)
if runtime_section:
sections.append(runtime_section)
execution_context = (build_input.execution_context or "").strip()
if execution_context:
sections.append(f"# Execution Context\n\n{execution_context}")
@ -338,8 +359,18 @@ class ContextBuilder:
rows.append(f"User ID: {session_context.user_id}")
if session_context.channel:
rows.append(f"Channel: {session_context.channel}")
if session_context.channel_kind:
rows.append(f"Channel Kind: {session_context.channel_kind}")
if session_context.account_id:
rows.append(f"Account ID: {session_context.account_id}")
if session_context.peer_id:
rows.append(f"Peer ID: {session_context.peer_id}")
if session_context.peer_type:
rows.append(f"Peer Type: {session_context.peer_type}")
if session_context.chat_id:
rows.append(f"Chat ID: {session_context.chat_id}")
if session_context.thread_id:
rows.append(f"Thread ID: {session_context.thread_id}")
if session_context.parent_session_id:
rows.append(f"Parent Session ID: {session_context.parent_session_id}")
@ -347,6 +378,31 @@ class ContextBuilder:
return None
return "# Current Session\n\n" + "\n".join(rows)
def _render_runtime_section(self, runtime_context: RuntimeContext | None) -> str | None:
"""Render date/time facts captured for the current model run."""
if runtime_context is None:
return None
rows: list[str] = []
if runtime_context.utc_datetime:
rows.append(f"Current UTC time: {runtime_context.utc_datetime}")
if runtime_context.local_datetime:
rows.append(f"Current local time: {runtime_context.local_datetime}")
if runtime_context.timezone:
rows.append(f"Local timezone: {runtime_context.timezone}")
if runtime_context.utc_offset:
rows.append(f"Local UTC offset: {runtime_context.utc_offset}")
if not rows:
return None
return (
"# Current Date and Time\n\n"
+ "\n".join(rows)
+ "\n\nUse this section as authoritative for relative date/time references such as "
'"today", "tomorrow", "now", "this week", and "next month".'
)
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
"""把已激活 skill 转成显式消息。

View File

@ -24,7 +24,7 @@ from beaver.skills.learning.eval import SkillDraftEvaluator
from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillSpecStore
from beaver.tasks import TaskExecutionPlanner, TaskService, ValidationService
from beaver.tasks import TaskExecutionPlanner, TaskService
from beaver.tasks.skill_resolver import TaskSkillResolver
from beaver.skills import SkillAssembler, SkillsLoader
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
@ -44,6 +44,7 @@ from beaver.tools.builtins import (
SpawnTool,
SessionSearchTool,
SkillManageTool,
SkillViewTool,
SkillsListTool,
TerminalTool,
TodoTool,
@ -91,7 +92,6 @@ class EngineLoadResult:
task_skill_resolver: TaskSkillResolver | None = None
task_service: TaskService | None = None
task_execution_planner: TaskExecutionPlanner | None = None
validation_service: ValidationService | None = None
mcp_manager: MCPConnectionManager | None = None
mcp_report: dict[str, dict] = field(default_factory=dict)
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
@ -166,7 +166,6 @@ class EngineLoader:
task_skill_resolver: TaskSkillResolver | None = None,
task_service: TaskService | None = None,
task_execution_planner: TaskExecutionPlanner | None = None,
validation_service: ValidationService | None = None,
) -> None:
self.config = config or load_config(workspace=workspace, config_path=config_path)
configured_workspace = self.config.agents_defaults.workspace
@ -192,7 +191,6 @@ class EngineLoader:
self._task_skill_resolver = task_skill_resolver
self._task_service = task_service
self._task_execution_planner = task_execution_planner
self._validation_service = validation_service
def load(self) -> EngineLoadResult:
"""装配当前主链需要的最小 runtime 对象。"""
@ -223,16 +221,17 @@ class EngineLoader:
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
SkillManageTool(),
CronTool(),
]
@ -276,7 +275,6 @@ class EngineLoader:
)
task_service = self._task_service or TaskService(workspace / "tasks")
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
validation_service = self._validation_service or ValidationService()
mcp_manager = MCPConnectionManager(
self.config.tools.mcp_servers,
authz_config=self.config.authz,
@ -311,7 +309,6 @@ class EngineLoader:
task_skill_resolver=task_skill_resolver,
task_service=task_service,
task_execution_planner=task_execution_planner,
validation_service=validation_service,
mcp_manager=mcp_manager,
)
if self._session_manager is None:

View File

@ -4,12 +4,16 @@ from __future__ import annotations
import asyncio
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from beaver.engine.context import ContextBuildInput, SessionContext, SkillContext
from beaver.engine.context import ContextBuildInput, RuntimeContext, SessionContext, SkillContext
from beaver.foundation.events import ChannelIdentity
from beaver.memory.runs import RunRecord, SkillEffectRecord
from beaver.skills.learning import RunReceiptContext
from beaver.skills.catalog.utils import strip_frontmatter
@ -26,6 +30,17 @@ TOOL_FAILURE_GUIDANCE_PROMPT = (
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
)
RAW_TOOL_CALL_FALLBACK = (
"The run reached the configured tool-call limit before producing a reliable final answer. "
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
"Please request a revision to continue the task."
)
_RAW_TOOL_CALL_RE = re.compile(
r"^\s*<tool_call\b[\s\S]*?</tool_call>\s*$|^\s*<function=[^>]+>[\s\S]*?</function>\s*$",
re.IGNORECASE,
)
@dataclass(slots=True)
class AgentProfile:
@ -34,9 +49,10 @@ class AgentProfile:
name: str = "default"
system_prompt: str = ""
default_model: str = "gpt-4.1-mini"
max_tokens: int = 4096
max_tokens: int | None = None
max_context_messages: int = 1000
temperature: float = 0.2
max_tool_iterations: int = 8
max_tool_iterations: int = 30
@dataclass(slots=True)
@ -74,6 +90,7 @@ class AgentLoop:
self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._active_direct_task: asyncio.Task[Any] | None = None
self._running = False
self._stop_requested = False
@ -115,6 +132,8 @@ class AgentLoop:
if item.future.cancelled():
continue
previous_direct_task = self._active_direct_task
self._active_direct_task = asyncio.current_task()
try:
result = await self._process_direct_impl(item.task, **item.kwargs)
except asyncio.CancelledError:
@ -127,6 +146,8 @@ class AgentLoop:
else:
if not item.future.done():
item.future.set_result(result)
finally:
self._active_direct_task = previous_direct_task
finally:
if self._run_queue is not None:
while True:
@ -168,6 +189,9 @@ class AgentLoop:
if self._stop_requested:
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
if asyncio.current_task() is self._active_direct_task:
return await self._process_direct_impl(task, **kwargs)
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
return await future
@ -225,6 +249,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
@ -274,6 +299,7 @@ class AgentLoop:
pinned_skill_contexts=pinned_skill_contexts,
allow_candidate_generation=allow_candidate_generation,
intent_agent_decision=intent_agent_decision,
channel_identity=channel_identity,
)
async def _process_direct_impl(
@ -311,6 +337,7 @@ class AgentLoop:
pinned_skill_contexts: list[SkillContext] | None = None,
allow_candidate_generation: bool = False,
intent_agent_decision: dict[str, Any] | None = None,
channel_identity: ChannelIdentity | None = None,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
@ -348,7 +375,7 @@ class AgentLoop:
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
resolved_embedding_model = embedding_model or config.default_embedding_model
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
resolved_max_tokens = max_tokens or self.profile.max_tokens
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
resolved_temperature = self.profile.temperature if temperature is None else temperature
resolved_max_tool_iterations = (
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
@ -446,7 +473,7 @@ class AgentLoop:
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
]
if not include_skill_assembly or thinking_enabled is False:
if not include_skill_assembly:
activated_skills = self._merge_skill_contexts(pinned_skills, [])
else:
skill_query = skill_selection_context or task
@ -512,8 +539,6 @@ class AgentLoop:
if not include_tools:
selected_tool_specs = []
elif thinking_enabled is False:
selected_tool_specs = tool_registry.list_specs()
else:
selected_tool_specs = await tool_assembler.assemble(
task_description=task,
@ -543,7 +568,10 @@ class AgentLoop:
build_input = ContextBuildInput(
base_system_prompt=self.profile.system_prompt,
history=session_manager.get_history(resolved_session_id),
history=session_manager.get_history(
resolved_session_id,
max_messages=max(1, self.profile.max_context_messages),
),
current_user_input=task,
memory_snapshot=memory_snapshot,
activated_skills=activated_skills,
@ -552,8 +580,16 @@ class AgentLoop:
source=source,
model=resolved_model,
user_id=user_id,
channel=channel_identity.channel_id if channel_identity else None,
channel_kind=channel_identity.kind if channel_identity else None,
account_id=channel_identity.account_id if channel_identity else None,
peer_id=channel_identity.peer_id if channel_identity else None,
peer_type=channel_identity.peer_type if channel_identity else None,
chat_id=channel_identity.peer_id if channel_identity else None,
thread_id=channel_identity.thread_id if channel_identity else None,
parent_session_id=parent_session_id,
),
runtime_context=self._current_runtime_context(),
execution_context=execution_context,
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
)
@ -693,6 +729,7 @@ class AgentLoop:
tool_calls=assistant_tool_calls or None,
finish_reason=response.finish_reason,
reasoning=response.reasoning_content,
context_visible=not bool(assistant_tool_calls),
source=source,
title=title,
model=final_model,
@ -707,7 +744,11 @@ class AgentLoop:
if not response.has_tool_calls:
final_text = response.content or ""
final_finish_reason = response.finish_reason or "stop"
if self._looks_like_raw_tool_call(final_text):
final_text = RAW_TOOL_CALL_FALLBACK
final_finish_reason = "invalid_tool_call_text"
else:
final_finish_reason = response.finish_reason or "stop"
break
if iterations >= resolved_max_tool_iterations:
@ -719,10 +760,7 @@ class AgentLoop:
temperature=resolved_temperature,
thinking_enabled=thinking_enabled,
)
final_text = finalized or (
"Tool loop stopped after reaching the configured iteration limit, "
"and no final answer was produced."
)
final_text = finalized or RAW_TOOL_CALL_FALLBACK
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
session_manager.append_message(
resolved_session_id,
@ -873,21 +911,18 @@ class AgentLoop:
provider: Any,
messages: list[dict[str, Any]],
model: str,
max_tokens: int,
max_tokens: int | None,
temperature: float,
thinking_enabled: bool | None,
) -> str:
final_messages = [
*messages,
{
"role": "system",
"content": (
"The configured tool iteration budget is exhausted. Do not call tools. "
"Produce the best final answer from the existing conversation and tool results. "
"State uncertainty explicitly."
),
},
]
final_messages = AgentLoop._with_system_guidance(
messages,
(
"The configured tool iteration budget is exhausted. Do not call tools. "
"Produce the best final answer from the existing conversation and tool results. "
"State uncertainty explicitly."
),
)
kwargs: dict[str, Any] = {
"messages": final_messages,
"tools": None,
@ -898,7 +933,27 @@ class AgentLoop:
if thinking_enabled is not None:
kwargs["thinking_enabled"] = thinking_enabled
response = await provider.chat(**kwargs)
return (response.content or "").strip()
if response.has_tool_calls:
return ""
content = (response.content or "").strip()
if AgentLoop._looks_like_raw_tool_call(content):
return ""
return content
@staticmethod
def _looks_like_raw_tool_call(content: str | None) -> bool:
if not content:
return False
return bool(_RAW_TOOL_CALL_RE.match(content))
@staticmethod
def _with_system_guidance(messages: list[dict[str, Any]], guidance: str) -> list[dict[str, Any]]:
copied = [dict(message) for message in messages]
if copied and copied[0].get("role") == "system":
existing = str(copied[0].get("content") or "").strip()
copied[0]["content"] = "\n\n".join(part for part in (existing, guidance.strip()) if part)
return copied
return [{"role": "system", "content": guidance.strip()}, *copied]
@staticmethod
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
@ -1133,3 +1188,49 @@ class AgentLoop:
@staticmethod
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@staticmethod
def _current_runtime_context() -> RuntimeContext:
utc_now = datetime.now(timezone.utc)
timezone_name = AgentLoop._configured_timezone_name()
local_now = datetime.now().astimezone()
rendered_timezone = local_now.tzname()
if timezone_name:
try:
local_now = utc_now.astimezone(ZoneInfo(timezone_name))
rendered_timezone = timezone_name
except ZoneInfoNotFoundError:
rendered_timezone = local_now.tzname() or timezone_name
return RuntimeContext(
utc_datetime=utc_now.isoformat(),
local_datetime=local_now.isoformat(),
timezone=rendered_timezone,
utc_offset=AgentLoop._format_utc_offset(local_now),
)
@staticmethod
def _configured_timezone_name() -> str | None:
for value in (os.getenv("BEAVER_RUNTIME_TIMEZONE"), os.getenv("TZ")):
cleaned = (value or "").strip()
if cleaned:
return cleaned
try:
timezone_file = "/etc/timezone"
if os.path.exists(timezone_file):
with open(timezone_file, encoding="utf-8") as file:
cleaned = file.read().strip()
if cleaned:
return cleaned
except OSError:
return None
return None
@staticmethod
def _format_utc_offset(value: datetime) -> str | None:
raw = value.strftime("%z")
if not raw:
return None
return f"{raw[:3]}:{raw[3:]}"

View File

@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
"model": model or self.default_model,
"system": system_prompt or "",
"messages": anthropic_messages,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
resolved_max_tokens = (
_default_max_tokens_for_model(model or self.default_model)
if max_tokens is None
else max(1, max_tokens)
)
kwargs["max_tokens"] = resolved_max_tokens
if tools:
kwargs["tools"] = _convert_tools(tools)
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
return self.default_model
def _default_max_tokens_for_model(model: str) -> int:
"""Return a conservative native output ceiling for Anthropic Messages."""
normalized = model.lower().replace("_", "-")
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
return 64_000
if "haiku" in normalized:
return 4_096
return 8_192
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
converted: list[dict[str, Any]] = []

View File

@ -88,7 +88,7 @@ class LLMProvider(ABC):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:

View File

@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
messages: list[dict],
tools: list[dict] | None,
model: str,
max_tokens: int,
max_tokens: int | None,
temperature: float,
thinking_enabled: bool | None,
) -> LLMResponse:

View File

@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:

View File

@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
kwargs: dict[str, Any] = {
"model": model or self.default_model,
"messages": self.sanitize_empty_content(messages),
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
if max_tokens is not None:
kwargs["max_tokens"] = max(1, max_tokens)
if tools:
kwargs.update(tools=tools, tool_choice="auto")
try:

View File

@ -119,13 +119,23 @@ class LiteLLMProvider(LLMProvider):
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
sanitized = []
system_contents: list[str] = []
for message in messages:
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
if clean.get("role") == "system":
content = clean.get("content")
if isinstance(content, str) and content.strip():
system_contents.append(content.strip())
elif content is not None:
system_contents.append(str(content))
continue
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
sanitized.append(clean)
if system_contents:
sanitized.insert(0, {"role": "system", "content": "\n\n".join(system_contents)})
return sanitized
@staticmethod
@ -187,7 +197,7 @@ class LiteLLMProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -200,10 +210,11 @@ class LiteLLMProvider(LLMProvider):
kwargs: dict[str, Any] = {
"model": resolved_model,
"messages": sanitized_messages,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
"timeout": self.request_timeout_seconds or 45.0,
}
if max_tokens is not None:
kwargs["max_tokens"] = max(1, max_tokens)
if self.api_key:
kwargs["api_key"] = self.api_key
if self.api_base:

View File

@ -84,8 +84,10 @@ class MessageRecord:
payload["task_id"] = self.event_payload.get("task_id")
if self.event_payload.get("task_status"):
payload["task_status"] = self.event_payload.get("task_status")
if self.event_payload.get("validation_status"):
payload["validation_status"] = self.event_payload.get("validation_status")
if self.event_payload.get("evidence_status"):
payload["evidence_status"] = self.event_payload.get("evidence_status")
if self.event_payload.get("acceptance_state"):
payload["acceptance_state"] = self.event_payload.get("acceptance_state")
if self.event_payload.get("feedback_state"):
payload["feedback_state"] = self.event_payload.get("feedback_state")
if self.event_payload.get("feedback_error"):

View File

@ -13,6 +13,7 @@ from .schema import (
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
ChannelConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
@ -73,6 +74,7 @@ def load_config(
embedding=_parse_embedding(data),
tools=_parse_tools(data.get("tools")),
authz=_parse_authz(data.get("authz")),
channels=_parse_channels(data.get("channels")),
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
config_path=path,
)
@ -86,6 +88,25 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
model=_string(defaults.get("model") or data.get("model")),
provider=_string(defaults.get("provider") or data.get("provider")),
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
max_tokens=_int(_first_config_value(
defaults.get("maxTokens"),
defaults.get("max_tokens"),
data.get("maxTokens"),
data.get("max_tokens"),
)),
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
max_context_messages=_int(
defaults.get("maxContextMessages")
or defaults.get("max_context_messages")
or data.get("maxContextMessages")
or data.get("max_context_messages")
),
max_tool_iterations=_int(_first_config_value(
defaults.get("maxToolIterations"),
defaults.get("max_tool_iterations"),
data.get("maxToolIterations"),
data.get("max_tool_iterations"),
)),
)
@ -177,6 +198,48 @@ def _parse_authz(raw: Any) -> AuthzConfig:
)
def _parse_channels(raw: Any) -> dict[str, ChannelConfig]:
channels: dict[str, ChannelConfig] = {}
for channel_id, payload in _as_dict(raw).items():
cleaned_id = str(channel_id).strip()
if not cleaned_id:
continue
channels[cleaned_id] = _parse_channel_config(payload)
return channels
def _parse_channel_config(payload: Any) -> ChannelConfig:
data = _as_dict(payload)
return ChannelConfig(
enabled=_bool(data.get("enabled"), default=False),
kind=_string(data.get("kind")) or "",
mode=_string(data.get("mode")) or "webhook",
account_id=_string(data.get("accountId") or data.get("account_id")) or "",
display_name=_string(data.get("displayName") or data.get("display_name")) or "",
config=_normalize_config_map(data.get("config")),
secrets=_string_dict(data.get("secrets")),
)
def _normalize_config_map(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
return {
_camel_to_snake_key(str(key)): item
for key, item in value.items()
if str(key).strip()
}
def _camel_to_snake_key(value: str) -> str:
result: list[str] = []
for char in value:
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
data = _as_dict(raw)
return BackendIdentityConfig(
@ -192,6 +255,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _first_config_value(*values: Any) -> Any:
for value in values:
if value not in (None, ""):
return value
return None
def _string(value: Any) -> str | None:
if value is None:
return None
@ -217,6 +287,13 @@ def _float(value: Any) -> float | None:
return float(value)
def _int(value: Any) -> int | None:
parsed = _float(value)
if parsed is None:
return None
return int(parsed)
def _bool(value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value

View File

@ -25,6 +25,10 @@ class AgentDefaultsConfig:
model: str | None = None
provider: str | None = None
embedding_model: str | None = None
max_tokens: int | None = None
temperature: float | None = None
max_context_messages: int | None = None
max_tool_iterations: int | None = None
@dataclass(slots=True)
@ -87,6 +91,19 @@ class AuthzConfig:
outlook_mcp_url: str = ""
@dataclass(slots=True)
class ChannelConfig:
"""One configured channel adapter instance."""
enabled: bool = False
kind: str = ""
mode: str = "webhook"
account_id: str = ""
display_name: str = ""
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
@dataclass(slots=True)
class BackendIdentityConfig:
"""This backend's AuthZ client identity."""
@ -107,6 +124,7 @@ class BeaverConfig:
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
authz: AuthzConfig = field(default_factory=AuthzConfig)
channels: dict[str, ChannelConfig] = field(default_factory=dict)
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
config_path: Path | None = None

View File

@ -1,5 +1,5 @@
"""Event contracts and dispatch helpers."""
from .message_bus import InboundMessage, MessageBus, OutboundMessage
from .message_bus import ChannelIdentity, InboundMessage, MessageBus, OutboundMessage
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
__all__ = ["ChannelIdentity", "InboundMessage", "MessageBus", "OutboundMessage"]

View File

@ -9,12 +9,58 @@ from typing import Any
from uuid import uuid4
@dataclass(slots=True)
class ChannelIdentity:
"""Normalized channel routing identity.
`channel_id` is the Beaver adapter instance id, not the platform kind.
"""
channel_id: str
kind: str
account_id: str
peer_id: str
thread_id: str | None = None
peer_type: str = "unknown"
user_id: str | None = None
message_id: str | None = None
def validation_error(self) -> str | None:
if not self.channel_id.strip():
return "channel_id is required"
if not self.account_id.strip():
return "account_id is required"
if not self.peer_id.strip():
return "peer_id is required"
return None
def session_id(self) -> str:
parts = [self.channel_id, self.account_id, self.peer_id]
if self.thread_id:
parts.append(self.thread_id)
return ":".join(_clean_session_part(part) for part in parts)
def dedupe_key(self) -> str | None:
if not self.message_id:
return None
return f"{self.session_id()}:{_clean_session_part(self.message_id)}"
def _clean_session_part(value: str) -> str:
cleaned = str(value).strip()
if not cleaned:
return "unknown"
return cleaned.replace(":", "_")
@dataclass(slots=True)
class InboundMessage:
"""A minimal inbound message accepted by the gateway bridge."""
channel: str
content: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
session_id: str | None = None
user_id: str | None = None
title: str | None = None
@ -35,6 +81,8 @@ class OutboundMessage:
content: str
session_id: str | None
finish_reason: str
content_type: str = "text"
channel_identity: ChannelIdentity | None = None
message_id: str = field(default_factory=lambda: str(uuid4()))
run_id: str | None = None
provider_name: str | None = None

View File

@ -1,7 +1,17 @@
"""Channel interfaces."""
from .base import ChannelAdapter
from .base import ChannelInboundSink
from .external_connector import ExternalConnectorChannel
from .manager import ChannelManager
from .memory import MemoryChannelAdapter
from .terminal_websocket import TerminalWebSocketAdapter
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
__all__ = [
"ChannelAdapter",
"ChannelInboundSink",
"ExternalConnectorChannel",
"ChannelManager",
"MemoryChannelAdapter",
"TerminalWebSocketAdapter",
]

View File

@ -2,16 +2,17 @@
from __future__ import annotations
from typing import Protocol
from typing import Any, Protocol
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.foundation.events import InboundMessage, OutboundMessage
class ChannelAdapter(Protocol):
"""Minimal contract every gateway channel must implement."""
"""Minimal contract every runtime channel adapter must implement."""
name: str
bus: MessageBus
channel_id: str
kind: str
mode: str
async def start(self) -> None:
"""Prepare the channel before messages are routed."""
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
async def send(self, message: OutboundMessage) -> None:
"""Deliver an outbound message to the concrete channel."""
class ChannelInboundSink(Protocol):
"""Runtime callback used by adapters to submit normalized inbound messages."""
async def accept_inbound(self, message: InboundMessage) -> Any:
"""Accept a normalized inbound message from an adapter."""

View File

@ -0,0 +1,29 @@
"""Channel connection setup layer."""
from .connectors import ChannelConnector, ChannelConnectorRegistry
from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore
from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
from .telegram import TelegramConnector
__all__ = [
"ChannelConnector",
"ChannelConnectorRegistry",
"ConnectorMessageDedupeRecord",
"DedupeBeginResult",
"MessageDedupeStore",
"ExternalConnectorBase",
"FeishuConnector",
"WeixinConnector",
"ConnectorSidecarClient",
"ChannelConnection",
"ChannelRuntimeSpec",
"PairingSession",
"ValidationResult",
"ChannelConnectionStore",
"CredentialStore",
"PairingTokenStore",
"TelegramConnector",
]

View File

@ -0,0 +1,93 @@
"""Channel connector registry."""
from __future__ import annotations
from typing import Protocol
from beaver.foundation.config.schema import ChannelConfig
from .models import ChannelRuntimeSpec, ValidationResult
from .store import ChannelConnectionStore, CredentialStore
class ChannelConnector(Protocol):
kind: str
async def validate(self, connection_id: str) -> ValidationResult:
...
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
...
async def revoke(self, connection_id: str) -> None:
...
class ChannelConnectorRegistry:
def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self._connectors: dict[str, ChannelConnector] = {}
def register(self, connector: ChannelConnector) -> None:
kind = connector.kind.strip()
if not kind:
raise ValueError("Connector kind is required")
if kind in self._connectors:
raise ValueError(f"Connector already registered: {kind}")
self._connectors[kind] = connector
def connectors(self) -> list[dict[str, str]]:
return [{"kind": kind} for kind in sorted(self._connectors)]
def connector_for_kind(self, kind: str) -> ChannelConnector:
return self._connector(kind)
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
connector = self._connector(connection.kind)
result = await connector.validate(connection_id)
self.connection_store.update_status(
connection_id,
status=result.status,
last_error=result.error,
)
return result
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
return await self._connector(connection.kind).materialize_runtime(connection_id)
async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]:
specs: list[ChannelRuntimeSpec] = []
for connection in self.connection_store.list():
if connection.status not in {"connected", "running"}:
continue
specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id))
return specs
async def materialize_channel_configs(self) -> dict[str, ChannelConfig]:
channels: dict[str, ChannelConfig] = {}
for spec in await self.materialize_connected_runtime_specs():
secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {}
channels[spec.channel_id] = ChannelConfig(
enabled=True,
kind=spec.kind,
mode=spec.mode,
account_id=spec.account_id,
display_name=spec.display_name,
config=dict(spec.config),
secrets=secrets,
)
return channels
async def revoke(self, connection_id: str) -> None:
connection = self.connection_store.get(connection_id)
await self._connector(connection.kind).revoke(connection_id)
self.connection_store.revoke(connection_id)
def _connector(self, kind: str) -> ChannelConnector:
connector = self._connectors.get(kind)
if connector is None:
raise KeyError(f"Connector not registered: {kind}")
return connector

View File

@ -0,0 +1,144 @@
"""Bridge event dedupe store for external connector retries."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
@dataclass(slots=True)
class ConnectorMessageDedupeRecord:
dedupe_key: str
connection_id: str
event_id: str
status: str
first_seen_at: str
updated_at: str
delivery_attempts: int
message_id: str | None = None
last_error: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord":
return cls(
dedupe_key=str(data.get("dedupe_key") or ""),
connection_id=str(data.get("connection_id") or ""),
event_id=str(data.get("event_id") or ""),
status=str(data.get("status") or "processing"),
first_seen_at=str(data.get("first_seen_at") or _iso_now()),
updated_at=str(data.get("updated_at") or _iso_now()),
delivery_attempts=int(data.get("delivery_attempts") or 0),
message_id=str(data["message_id"]) if data.get("message_id") is not None else None,
last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
)
@dataclass(slots=True)
class DedupeBeginResult:
should_process: bool
dedupe_key: str
status: str
http_status: int
retry_after_seconds: int | None
record: ConnectorMessageDedupeRecord
class MessageDedupeStore:
def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None:
self.path = Path(path)
self.processing_ttl_seconds = int(processing_ttl_seconds)
self._lock = Lock()
def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult:
dedupe_key = f"{connection_id}:{event_id}"
now = _iso_now()
with self._lock:
data = self._load()
raw = data["records"].get(dedupe_key)
if isinstance(raw, dict):
record = ConnectorMessageDedupeRecord.from_dict(raw)
if record.status == "completed":
return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record)
if record.status == "processing" and not self._is_stale(record, now):
return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record)
record.status = "processing"
record.updated_at = now
record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt))
record.last_error = None
else:
record = ConnectorMessageDedupeRecord(
dedupe_key=dedupe_key,
connection_id=connection_id,
event_id=event_id,
status="processing",
first_seen_at=now,
updated_at=now,
delivery_attempts=max(1, int(delivery_attempt)),
)
data["records"][dedupe_key] = record.to_dict()
self._save(data)
return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record)
def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord:
return self._mark(dedupe_key, status="completed", message_id=message_id, error=None)
def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord:
return self._mark(dedupe_key, status="failed", message_id=None, error=error)
def _mark(
self,
dedupe_key: str,
*,
status: str,
message_id: str | None,
error: str | None,
) -> ConnectorMessageDedupeRecord:
with self._lock:
data = self._load()
raw = data["records"].get(dedupe_key)
if not isinstance(raw, dict):
raise KeyError(dedupe_key)
record = ConnectorMessageDedupeRecord.from_dict(raw)
record.status = status
record.updated_at = _iso_now()
record.message_id = message_id or record.message_id
record.last_error = error
data["records"][dedupe_key] = record.to_dict()
self._save(data)
return record
def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool:
age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds()
return age >= self.processing_ttl_seconds
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"records": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"records": {}}
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
return {"records": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)

View File

@ -0,0 +1,131 @@
"""Sidecar-backed channel connectors."""
from __future__ import annotations
from typing import Any
from .models import ChannelRuntimeSpec, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore
class ExternalConnectorBase:
kind = ""
capabilities: list[str] = []
def __init__(
self,
*,
connection_store: ChannelConnectionStore,
credential_store: CredentialStore,
sidecar_client: ConnectorSidecarClient | Any,
sidecar_base_url: str,
) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self.sidecar_client = sidecar_client
self.sidecar_base_url = sidecar_base_url
async def start_session(
self,
*,
display_name: str,
owner_user_id: str | None,
options: dict[str, Any],
) -> dict[str, Any]:
connection = self.connection_store.create(
kind=self.kind,
mode="sidecar",
display_name=display_name or self.kind,
account_id="",
owner_user_id=owner_user_id,
auth_type="connector_session",
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
capabilities=list(self.capabilities),
)
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
payload = {
"kind": self.kind,
"connectionId": connection.connection_id,
"channelId": connection.channel_id,
"displayName": connection.display_name,
"callbackBaseUrl": "",
"options": dict(options),
}
view = dict(await self.sidecar_client.start_session(payload))
connection.pairing_session_id = str(view.get("sessionId") or "")
self.connection_store.update(connection)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
async def poll_session(self, session_id: str) -> dict[str, Any]:
view = dict(await self.sidecar_client.get_session(session_id))
connection = self._connection_for_session(session_id)
status = str(view.get("status") or "")
if status == "connected":
connection.account_id = str(view.get("accountId") or connection.account_id)
connection.display_name = str(view.get("displayName") or connection.display_name)
metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {}
state_ref = metadata.get("stateRef")
if state_ref:
connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref})
self.connection_store.update(connection)
self.connection_store.update_status(connection.connection_id, status="connected", last_error=None)
elif status in {"expired", "error", "cancelled"}:
self.connection_store.update_status(
connection.connection_id,
status="error",
last_error=str(view.get("error") or status),
)
view["connectionId"] = connection.connection_id
view["channelId"] = connection.channel_id
return view
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
if connection.status in {"connected", "running"}:
return ValidationResult(
ok=True,
status="connected",
account_id=connection.account_id,
display_name=connection.display_name,
)
return ValidationResult(ok=False, status=connection.status, error=connection.last_error)
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
if connection.status not in {"connected", "running"}:
raise ValueError(f"Connection is not connected: {connection.connection_id}")
return ChannelRuntimeSpec(
channel_id=connection.channel_id,
kind="external_connector",
mode="http",
account_id=connection.account_id,
display_name=connection.display_name,
config={
"platformKind": self.kind,
"connectionId": connection.connection_id,
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
},
secrets_ref=None,
)
async def revoke(self, connection_id: str) -> None:
await self.sidecar_client.logout(connection_id)
def _connection_for_session(self, session_id: str):
for connection in self.connection_store.list():
if connection.pairing_session_id == session_id:
return connection
raise KeyError(session_id)
class WeixinConnector(ExternalConnectorBase):
kind = "weixin"
capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"]
class FeishuConnector(ExternalConnectorBase):
kind = "feishu"
capabilities = ["receive_text", "send_text", "receive_media", "groups"]

View File

@ -0,0 +1,117 @@
"""Channel connection setup models."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any
CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"}
def iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class ChannelConnection:
connection_id: str
owner_user_id: str | None
channel_id: str
kind: str
mode: str
display_name: str
account_id: str
status: str
auth_type: str
credentials_ref: str | None = None
connector_ref: str | None = None
pairing_session_id: str | None = None
runtime_config: dict[str, Any] = field(default_factory=dict)
capabilities: list[str] = field(default_factory=list)
created_at: str = field(default_factory=iso_now)
updated_at: str = field(default_factory=iso_now)
last_seen_at: str | None = None
last_error: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ChannelConnection":
return cls(
connection_id=str(data.get("connection_id") or ""),
owner_user_id=_optional_string(data.get("owner_user_id")),
channel_id=str(data.get("channel_id") or ""),
kind=str(data.get("kind") or ""),
mode=str(data.get("mode") or ""),
display_name=str(data.get("display_name") or ""),
account_id=str(data.get("account_id") or ""),
status=str(data.get("status") or "draft"),
auth_type=str(data.get("auth_type") or ""),
credentials_ref=_optional_string(data.get("credentials_ref")),
connector_ref=_optional_string(data.get("connector_ref")),
pairing_session_id=_optional_string(data.get("pairing_session_id")),
runtime_config=dict(data.get("runtime_config") or {}),
capabilities=[str(item) for item in data.get("capabilities") or []],
created_at=str(data.get("created_at") or iso_now()),
updated_at=str(data.get("updated_at") or iso_now()),
last_seen_at=_optional_string(data.get("last_seen_at")),
last_error=_optional_string(data.get("last_error")),
)
@dataclass(slots=True)
class PairingSession:
pairing_session_id: str
kind: str
scope: str
token: str
status: str
expires_at_ms: int
created_at_ms: int
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PairingSession":
return cls(
pairing_session_id=str(data.get("pairing_session_id") or ""),
kind=str(data.get("kind") or ""),
scope=str(data.get("scope") or ""),
token=str(data.get("token") or ""),
status=str(data.get("status") or "pending"),
expires_at_ms=int(data.get("expires_at_ms") or 0),
created_at_ms=int(data.get("created_at_ms") or 0),
)
@dataclass(slots=True)
class ChannelRuntimeSpec:
channel_id: str
kind: str
mode: str
account_id: str
display_name: str
config: dict[str, Any] = field(default_factory=dict)
secrets_ref: str | None = None
external_endpoint: str | None = None
@dataclass(slots=True)
class ValidationResult:
ok: bool
status: str
account_id: str | None = None
display_name: str | None = None
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def _optional_string(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,39 @@
"""HTTP client for the generic external connector sidecar."""
from __future__ import annotations
from typing import Any
import httpx
class ConnectorSidecarClient:
def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_seconds = float(timeout_seconds)
async def get_connectors(self) -> list[dict[str, Any]]:
return await self._request("GET", "/connectors")
async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/connector-sessions", json=payload)
async def get_session(self, session_id: str) -> dict[str, Any]:
return await self._request("GET", f"/connector-sessions/{session_id}")
async def cancel_session(self, session_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})
async def logout(self, connection_id: str) -> dict[str, Any]:
return await self._request("POST", f"/connections/{connection_id}/logout", json={})
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
return await self._request("POST", "/send", json=payload)
async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
response.raise_for_status()
return response.json()

View File

@ -0,0 +1,222 @@
"""Persistent channel connection stores."""
from __future__ import annotations
import json
import time
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now
class ChannelConnectionStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def create(
self,
*,
kind: str,
mode: str,
display_name: str,
account_id: str,
owner_user_id: str | None,
auth_type: str,
runtime_config: dict[str, Any] | None = None,
capabilities: list[str] | None = None,
credentials_ref: str | None = None,
) -> ChannelConnection:
with self._lock:
data = self._load()
connection_id = f"conn_{uuid4().hex}"
channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}"
now = iso_now()
connection = ChannelConnection(
connection_id=connection_id,
owner_user_id=owner_user_id,
channel_id=channel_id,
kind=kind,
mode=mode,
display_name=display_name or channel_id,
account_id=account_id,
status="draft",
auth_type=auth_type,
credentials_ref=credentials_ref,
runtime_config=runtime_config or {},
capabilities=capabilities or [],
created_at=now,
updated_at=now,
)
data["connections"][connection_id] = connection.to_dict()
self._save(data)
return connection
def get(self, connection_id: str) -> ChannelConnection:
data = self._load()
raw = data["connections"].get(connection_id)
if not isinstance(raw, dict):
raise KeyError(connection_id)
return ChannelConnection.from_dict(raw)
def list(self) -> list[ChannelConnection]:
data = self._load()
return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)]
def update(self, connection: ChannelConnection) -> ChannelConnection:
with self._lock:
data = self._load()
if connection.connection_id not in data["connections"]:
raise KeyError(connection.connection_id)
connection.updated_at = iso_now()
data["connections"][connection.connection_id] = connection.to_dict()
self._save(data)
return connection
def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection:
if status not in CONNECTION_STATUSES:
raise ValueError(f"Unsupported connection status: {status}")
connection = self.get(connection_id)
connection.status = status
connection.last_error = last_error
if status in {"connected", "running"}:
connection.last_seen_at = iso_now()
return self.update(connection)
def revoke(self, connection_id: str) -> ChannelConnection:
return self.update_status(connection_id, status="revoked", last_error=None)
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"connections": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"connections": {}}
if not isinstance(data, dict) or not isinstance(data.get("connections"), dict):
return {"connections": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
class CredentialStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def put(self, *, kind: str, values: dict[str, Any]) -> str:
cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()}
ref = f"cred_{uuid4().hex}"
with self._lock:
data = self._load()
data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()}
self._save(data)
return ref
def get(self, ref: str) -> dict[str, str]:
data = self._load()
item = data["credentials"].get(ref)
if not isinstance(item, dict):
raise KeyError(ref)
values = item.get("values")
if not isinstance(values, dict):
return {}
return {str(key): str(value) for key, value in values.items()}
def redacted(self, ref: str | None) -> dict[str, str]:
if not ref:
return {}
try:
values = self.get(ref)
except KeyError:
return {}
return {key: "***" for key in values}
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"credentials": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"credentials": {}}
if not isinstance(data, dict) or not isinstance(data.get("credentials"), dict):
return {"credentials": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
class PairingTokenStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = Lock()
def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession:
now_ms = _now_ms()
session = PairingSession(
pairing_session_id=f"pair_{uuid4().hex}",
kind=kind,
scope=scope,
token=f"pair_{uuid4().hex}",
status="pending",
expires_at_ms=now_ms + int(ttl_seconds * 1000),
created_at_ms=now_ms,
)
with self._lock:
data = self._load()
data["sessions"][session.pairing_session_id] = session.to_dict()
self._save(data)
return session
def consume(self, token: str, *, expected_kind: str) -> PairingSession | None:
with self._lock:
data = self._load()
for key, raw in data["sessions"].items():
session = PairingSession.from_dict(raw)
if session.token != token or session.kind != expected_kind:
continue
if session.status != "pending" or session.expires_at_ms <= _now_ms():
return None
session.status = "consumed"
data["sessions"][key] = session.to_dict()
self._save(data)
return session
return None
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"sessions": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"sessions": {}}
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
return {"sessions": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _now_ms() -> int:
return int(time.time() * 1000)
def _slug(value: str) -> str:
text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower())
return "-".join(part for part in text.split("-") if part) or "channel"

View File

@ -0,0 +1,92 @@
"""Telegram channel connector."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .models import ChannelRuntimeSpec, ValidationResult
from .store import ChannelConnectionStore, CredentialStore
class TelegramConnector:
kind = "telegram"
def __init__(
self,
*,
connection_store: ChannelConnectionStore,
credential_store: CredentialStore,
client_factory: Callable[[str], Any] | None = None,
) -> None:
self.connection_store = connection_store
self.credential_store = credential_store
self.client_factory = client_factory or _default_client_factory
async def validate(self, connection_id: str) -> ValidationResult:
connection = self.connection_store.get(connection_id)
token = self._bot_token(connection.credentials_ref)
try:
client = self.client_factory(token)
raw = await client.get_me()
bot_id = _value(raw, "id")
username = _value(raw, "username")
first_name = _value(raw, "first_name") or "Telegram Bot"
account_id = f"telegram:{bot_id}" if bot_id else connection.account_id
display_name = f"{first_name} (@{username})" if username else first_name
connection.account_id = account_id
connection.display_name = display_name
connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"]
self.connection_store.update(connection)
return ValidationResult(
ok=True,
status="connected",
account_id=account_id,
display_name=display_name,
metadata={"username": username} if username else {},
)
except Exception as exc:
return ValidationResult(ok=False, status="error", error=str(exc))
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
connection = self.connection_store.get(connection_id)
if connection.status not in {"connected", "running"}:
raise ValueError(f"Connection is not connected: {connection.connection_id}")
return ChannelRuntimeSpec(
channel_id=connection.channel_id,
kind=connection.kind,
mode=connection.mode,
account_id=connection.account_id,
display_name=connection.display_name,
config=dict(connection.runtime_config),
secrets_ref=connection.credentials_ref,
)
async def revoke(self, connection_id: str) -> None:
# Telegram bot tokens do not have a Beaver-managed platform revoke action.
# The registry owns local connection state transitions.
return None
def _bot_token(self, credentials_ref: str | None) -> str:
if not credentials_ref:
raise ValueError("Telegram credentials are missing")
token = self.credential_store.get(credentials_ref).get("botToken")
if not token:
raise ValueError("botToken is required")
return token
def _value(raw: Any, key: str) -> str:
if isinstance(raw, dict):
value = raw.get(key)
else:
value = getattr(raw, key, None)
return str(value).strip() if value is not None else ""
def _default_client_factory(token: str) -> Any:
try:
from telegram import Bot
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc
return Bot(token=token)

View File

@ -0,0 +1,97 @@
"""Generic runtime channel backed by an external connector sidecar."""
from __future__ import annotations
import hashlib
from typing import Any
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
class ExternalConnectorChannel:
def __init__(
self,
*,
channel_id: str,
platform_kind: str,
connection_id: str,
account_id: str,
display_name: str,
sidecar_client: ConnectorSidecarClient | Any,
) -> None:
self.channel_id = channel_id
self.kind = "external_connector"
self.mode = "http"
self.platform_kind = platform_kind
self.connection_id = connection_id
self.account_id = account_id
self.display_name = display_name or channel_id
self.sidecar_client = sidecar_client
self.started = False
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
async def send(self, message: OutboundMessage) -> None:
identity = message.channel_identity
if identity is None:
raise ValueError("channel_identity is required for external connector sends")
metadata = {
"inboundMessageId": identity.message_id,
"sessionId": message.session_id,
}
context_token = _context_token(message)
if context_token:
metadata["contextToken"] = context_token
payload = {
"requestId": _request_id(message),
"connectionId": self.connection_id,
"channelId": self.channel_id,
"kind": self.platform_kind,
"target": {
"peerId": identity.peer_id,
"peerType": identity.peer_type,
"threadId": identity.thread_id,
},
"content": message.content,
"metadata": metadata,
}
await self.sidecar_client.send(payload)
def _request_id(message: OutboundMessage) -> str:
identity = message.channel_identity
channel = message.channel or (identity.channel_id if identity else "unknown")
session_id = message.session_id or (identity.session_id() if identity else "unknown")
message_id = str(message.message_id or "").strip()
if not message_id:
basis = "|".join(
[
message.content,
identity.message_id if identity and identity.message_id else "",
identity.peer_id if identity else "",
message.finish_reason,
]
)
message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
return f"out_{channel}:{session_id}:{message_id}"
def _context_token(message: OutboundMessage) -> str | None:
inbound_metadata = message.metadata.get("inbound_metadata")
if isinstance(inbound_metadata, dict):
value = _clean_optional(inbound_metadata.get("contextToken") or inbound_metadata.get("context_token"))
if value:
return value
return _clean_optional(message.metadata.get("contextToken") or message.metadata.get("context_token"))
def _clean_optional(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,116 @@
"""Generic fixed-schema text webhook channel adapter."""
from __future__ import annotations
import asyncio
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
class GenericWebhookAdapter:
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str = "",
inbound_sink: ChannelInboundSink,
response_timeout_seconds: float = 1800,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name or channel_id
self.inbound_sink = inbound_sink
self.response_timeout_seconds = max(1.0, float(response_timeout_seconds))
self.started = False
self._pending: dict[str, asyncio.Future[OutboundMessage]] = {}
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
for future in list(self._pending.values()):
if not future.done():
future.cancel()
self._pending.clear()
async def handle_webhook_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
text = str(payload.get("text") or "").strip()
peer_id = str(payload.get("peer_id") or "").strip()
message_id = str(payload.get("message_id") or "").strip()
thread_id = str(payload.get("thread_id") or "").strip() or None
peer_type = str(payload.get("peer_type") or "unknown").strip() or "unknown"
user_id = str(payload.get("user_id") or "").strip() or None
if not text:
return {"ok": False, "error": "text is required"}
if not peer_id:
return {"ok": False, "error": "peer_id is required"}
if not message_id:
return {"ok": False, "error": "message_id is required"}
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
)
inbound = InboundMessage(
channel=self.channel_id,
content=text,
user_id=user_id,
channel_identity=identity,
metadata={"webhook": {"peer_type": peer_type}},
)
future = asyncio.get_running_loop().create_future()
self._pending[inbound.message_id] = future
accept = await self.inbound_sink.accept_inbound(inbound)
if not accept.accepted:
self._pending.pop(inbound.message_id, None)
record = accept.record or {}
return {
"ok": accept.error is None,
"duplicate": accept.duplicate,
"pending": accept.pending,
"session_id": accept.session_id,
"status": record.get("status"),
"run_id": record.get("run_id"),
"reply": record.get("reply"),
"error": accept.error or record.get("error"),
}
try:
outbound = await asyncio.wait_for(future, timeout=self.response_timeout_seconds)
except asyncio.TimeoutError:
self._pending.pop(inbound.message_id, None)
return {
"ok": True,
"duplicate": False,
"pending": True,
"session_id": accept.session_id,
}
return {
"ok": outbound.finish_reason != "error",
"duplicate": False,
"pending": False,
"session_id": outbound.session_id,
"run_id": outbound.run_id,
"reply": outbound.content,
"error": outbound.metadata.get("error"),
}
async def send(self, message: OutboundMessage) -> None:
future = self._pending.pop(message.message_id, None)
if future is None or future.done():
message.metadata["delivery_status"] = "unclaimed"
return
future.set_result(message)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from beaver.foundation.events import MessageBus, OutboundMessage
@ -20,13 +21,17 @@ class ChannelManager:
self.started = False
def register(self, channel: ChannelAdapter) -> None:
if self.started:
raise RuntimeError("Cannot register channels after ChannelManager.start()")
if channel.name in self.channels:
raise ValueError(f"Channel already registered: {channel.name}")
if channel.bus is not self.bus:
raise ValueError("Channel must share the same MessageBus as ChannelManager")
self.channels[channel.name] = channel
if channel.channel_id in self.channels:
raise ValueError(f"Channel already registered: {channel.channel_id}")
self.channels[channel.channel_id] = channel
def unregister(self, channel_id: str) -> ChannelAdapter | None:
return self.channels.pop(channel_id, None)
def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None:
old = self.channels.get(channel.channel_id)
self.channels[channel.channel_id] = channel
return old
async def start(self) -> None:
started: list[ChannelAdapter] = []
@ -53,7 +58,13 @@ class ChannelManager:
if errors:
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
async def dispatch_outbound(
self,
stop_event: asyncio.Event,
*,
on_delivered: Callable[[OutboundMessage], Awaitable[None]] | None = None,
on_failed: Callable[[OutboundMessage, Exception | None], Awaitable[None]] | None = None,
) -> None:
"""Route bus outbound messages until stopped and the queue is drained."""
while True:
@ -68,9 +79,16 @@ class ChannelManager:
channel = self.channels.get(message.channel)
if channel is None:
self.undeliverable.append(message)
if on_failed is not None:
await on_failed(message, None)
continue
try:
await channel.send(message)
except Exception: # pragma: no cover - defensive channel isolation
except Exception as exc: # pragma: no cover - defensive channel isolation
self.undeliverable.append(message)
if on_failed is not None:
await on_failed(message, exc)
else:
if on_delivered is not None:
await on_delivered(message)

View File

@ -4,15 +4,27 @@ from __future__ import annotations
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
class MemoryChannelAdapter:
"""A local channel that stores outbound messages in memory."""
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
self.name = name
self.bus = bus
def __init__(
self,
inbound_sink: ChannelInboundSink,
*,
channel_id: str = "memory-dev",
kind: str = "memory",
mode: str = "webhook",
account_id: str = "memory",
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.inbound_sink = inbound_sink
self.started = False
self.sent_messages: list[OutboundMessage] = []
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
model: str | None = None,
provider_name: str | None = None,
embedding_model: str | None = None,
peer_id: str = "default",
thread_id: str | None = None,
message_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
"""Publish a text message from this channel into the shared bus."""
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
user_id=user_id,
message_id=message_id,
)
message = InboundMessage(
channel=self.name,
channel=self.channel_id,
content=content,
session_id=session_id,
user_id=user_id,
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
model=model,
provider_name=provider_name,
embedding_model=embedding_model,
channel_identity=identity,
metadata=metadata or {},
)
await self.bus.publish_inbound(message)
await self.inbound_sink.accept_inbound(message)
return message
async def publish_external_text(
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
the shared gateway bus.
"""
session_parts = [self.name, chat_id]
if thread_id:
session_parts.append(thread_id)
metadata = {
"chat_id": chat_id,
"message_id": message_id,
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
}
return await self.publish_text(
content,
session_id=":".join(str(part) for part in session_parts if str(part)),
user_id=user_id,
title=title,
peer_id=chat_id,
thread_id=thread_id,
message_id=message_id,
metadata=metadata,
)

View File

@ -0,0 +1 @@
"""Platform channel adapters."""

View File

@ -0,0 +1,138 @@
"""Shared helpers for platform channel adapters."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
@dataclass(slots=True)
class OutboundTarget:
peer_id: str | None
thread_id: str | None = None
peer_type: str = "unknown"
user_id: str | None = None
class PlatformDeliveryError(RuntimeError):
"""Raised when a platform client rejects a delivery."""
def config_bool(config: dict[str, Any], key: str, *, default: bool = False) -> bool:
value = config.get(key)
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return default
def config_list(config: dict[str, Any], key: str) -> list[str]:
value = config.get(key)
if value is None:
return []
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
text = str(value).strip()
return [text] if text else []
def chunk_text(text: str, *, max_chars: int) -> list[str]:
if max_chars <= 0:
raise ValueError("max_chars must be positive")
if not text:
return [""]
return [text[index : index + max_chars] for index in range(0, len(text), max_chars)]
def compact_media_summary(media_type: str, *, file_name: str | None = None) -> str:
label = str(media_type or "attachment").strip() or "attachment"
if file_name:
return f"[{label}: {file_name}]"
return f"[{label}]"
def target_from_session_id(session_id: str | None) -> OutboundTarget:
if not session_id:
return OutboundTarget(peer_id=None)
parts = str(session_id).split(":")
if len(parts) < 3:
return OutboundTarget(peer_id=None)
thread_id = parts[3] if len(parts) > 3 and parts[3] else None
return OutboundTarget(peer_id=parts[2] or None, thread_id=thread_id)
def outbound_target(message: OutboundMessage) -> OutboundTarget:
identity = message.channel_identity
if identity is None:
return target_from_session_id(message.session_id)
return OutboundTarget(
peer_id=identity.peer_id,
thread_id=identity.thread_id,
peer_type=identity.peer_type,
user_id=identity.user_id,
)
def mark_unclaimed(message: OutboundMessage) -> None:
message.metadata["delivery_status"] = "unclaimed"
def build_inbound_message(
*,
channel_id: str,
kind: str,
account_id: str,
peer_id: str,
content: str,
message_id: str | None,
peer_type: str,
user_id: str | None = None,
thread_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
identity = ChannelIdentity(
channel_id=channel_id,
kind=kind,
account_id=account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
)
return InboundMessage(
channel=channel_id,
content=content,
session_id=identity.session_id(),
user_id=user_id,
message_id=message_id or "",
channel_identity=identity,
metadata=metadata or {},
)
def allowed_by_policy(
*,
policy: str | None,
identifier: str | None,
allowlist: list[str],
default: str = "open",
) -> bool:
effective = (policy or default).strip().lower()
if effective == "disabled":
return False
if effective == "allowlist":
return bool(identifier and identifier in allowlist)
return True

View File

@ -0,0 +1,207 @@
"""Feishu/Lark channel adapter."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class FeishuAdapter:
"""Feishu/Lark bot adapter with injectable client support."""
KIND = "feishu"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode not in {"websocket", "webhook"}:
raise ValueError(f"Unsupported feishu mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(receive_id_type="chat_id", receive_id=target.peer_id, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
message = event.get("message") if isinstance(event.get("message"), dict) else {}
sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
peer_id = _string_or_none(message.get("chat_id"))
if not peer_id:
return None
message_id = _string_or_none(message.get("message_id"))
message_type = str(message.get("message_type") or "unknown")
chat_type = str(message.get("chat_type") or "unknown")
peer_type = "dm" if chat_type == "p2p" else "group"
user_id = _sender_open_id(sender)
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type == "group" and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "group" and config_bool(self.config, "requireMentionInGroups", default=False):
if not self._message_mentions_bot(message):
return None
content = self._message_content(message_type, message)
if not content:
return None
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_type,
"message_type": message_type,
}
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _message_content(self, message_type: str, message: dict[str, Any]) -> str:
content = _parse_json_object(message.get("content"))
if message_type == "text":
return str(content.get("text") or "").strip()
file_name = _string_or_none(content.get("file_name") or content.get("name"))
return compact_media_summary(message_type, file_name=file_name)
def _message_mentions_bot(self, message: dict[str, Any]) -> bool:
bot_open_id = _string_or_none(self.config.get("botOpenId"))
if not bot_open_id:
return False
mentions = message.get("mentions")
if not isinstance(mentions, list):
return False
for mention in mentions:
if not isinstance(mention, dict):
continue
mention_id = mention.get("id") if isinstance(mention.get("id"), dict) else {}
if _string_or_none(mention_id.get("open_id")) == bot_open_id:
return True
return False
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("appId")
self._require_secret("appSecret")
try:
import lark_oapi # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[feishu] to enable FeishuAdapter") from exc
raise RuntimeError("Feishu live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _parse_json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not isinstance(value, str):
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _sender_open_id(sender: dict[str, Any]) -> str | None:
sender_id = sender.get("sender_id") if isinstance(sender.get("sender_id"), dict) else {}
return _string_or_none(sender_id.get("open_id"))
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,206 @@
"""QQ Bot channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
allowed_by_policy,
build_inbound_message,
chunk_text,
compact_media_summary,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class QQBotAdapter:
"""QQ Bot API adapter with injectable client support."""
KIND = "qqbot"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
async def start(self) -> None:
if self._client is not None:
return
if self.mode != "websocket":
raise ValueError(f"Unsupported qqbot mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_event_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
platform_message_id = message.channel_identity.message_id if message.channel_identity else None
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(
peer_type=target.peer_type,
peer_id=target.peer_id,
content=chunk,
message_id=platform_message_id,
)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
event_type = str(payload.get("t") or payload.get("type") or "")
data = payload.get("d") if isinstance(payload.get("d"), dict) else payload
author = data.get("author") if isinstance(data.get("author"), dict) else {}
route = self._route(event_type, data, author)
if route is None:
return None
peer_id, peer_type, user_id, thread_id = route
if peer_type == "dm":
if not allowed_by_policy(
policy=self.config.get("dmPolicy"),
identifier=user_id or peer_id,
allowlist=config_list(self.config, "allowFrom"),
default="open",
):
return None
elif peer_type == "group":
if not allowed_by_policy(
policy=self.config.get("groupPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "groupAllowFrom"),
default="open",
):
return None
message_id = _string_or_none(data.get("id"))
content = str(data.get("content") or "").strip()
media_entries = self._media_entries(data)
if media_entries:
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
if not content:
return None
metadata = {
"event_type": event_type,
"message_id": message_id,
"peer_type": peer_type,
}
if media_entries:
metadata["media"] = media_entries
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _route(
self,
event_type: str,
data: dict[str, Any],
author: dict[str, Any],
) -> tuple[str, str, str | None, str | None] | None:
if event_type == "C2C_MESSAGE_CREATE":
peer_id = _string_or_none(author.get("user_openid"))
if not peer_id:
return None
return peer_id, "dm", peer_id, None
if event_type == "GROUP_AT_MESSAGE_CREATE":
peer_id = _string_or_none(data.get("group_openid"))
if not peer_id:
return None
return peer_id, "group", _string_or_none(author.get("member_openid")), None
if data.get("guild_id") and data.get("channel_id"):
peer_id = _string_or_none(data.get("channel_id"))
if not peer_id:
return None
return peer_id, "channel", _string_or_none(author.get("id")), _string_or_none(data.get("guild_id"))
return None
def _media_entries(self, data: dict[str, Any]) -> list[str]:
entries: list[str] = []
attachments = data.get("attachments")
if not isinstance(attachments, list):
return entries
for attachment in attachments:
if not isinstance(attachment, dict):
continue
media_type = str(attachment.get("content_type") or attachment.get("type") or "attachment")
entries.append(compact_media_summary(media_type, file_name=_string_or_none(attachment.get("filename"))))
return entries
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("appId")
self._require_secret("clientSecret")
try:
import aiohttp # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[qqbot] to enable QQBotAdapter") from exc
raise RuntimeError("QQBot live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,244 @@
"""Telegram channel adapter."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
build_inbound_message,
chunk_text,
compact_media_summary,
config_bool,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class TelegramAdapter:
"""Telegram Bot API adapter with injectable client support."""
KIND = "telegram"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
application_factory: Callable[[], Any] | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self._application_factory = application_factory
self._application: Any | None = None
self.max_message_chars = int(self.config.get("maxMessageChars") or 4096)
async def start(self) -> None:
if self._client is not None:
return
if self.mode == "polling":
self._application = self._build_application()
await self._application.initialize()
await self._application.start()
if getattr(self._application, "updater", None) is not None:
await self._application.updater.start_polling()
self._client = self._application.bot
return
if self.mode == "webhook":
self._client = self._build_bot()
return
raise ValueError(f"Unsupported telegram mode: {self.mode}")
async def stop(self) -> None:
if self._application is None:
return
updater = getattr(self._application, "updater", None)
if updater is not None:
await updater.stop()
await self._application.stop()
await self._application.shutdown()
self._application = None
async def handle_update_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
kwargs: dict[str, Any] = {"chat_id": target.peer_id}
if target.thread_id:
kwargs["message_thread_id"] = int(target.thread_id) if str(target.thread_id).isdigit() else target.thread_id
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_message(**kwargs, text=chunk)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
data = payload.get("message") or payload.get("edited_message")
if not isinstance(data, dict):
return None
chat = data.get("chat") if isinstance(data.get("chat"), dict) else {}
sender = data.get("from") if isinstance(data.get("from"), dict) else {}
peer_id = _string_or_none(chat.get("id"))
if not peer_id:
return None
chat_type = str(chat.get("type") or "unknown")
peer_type = self._peer_type(chat_type)
user_id = _string_or_none(sender.get("id"))
message_id = _string_or_none(data.get("message_id"))
thread_id = _string_or_none(data.get("message_thread_id"))
content = str(data.get("text") or data.get("caption") or "").strip()
media_entries = self._media_entries(data)
if media_entries:
content = "\n".join([part for part in [content, *media_entries] if part]).strip()
if not content:
return None
if peer_type in {"group", "channel"} and not self._group_allowed(peer_id, user_id):
return None
if peer_type == "dm" and not self._dm_allowed(user_id or peer_id):
return None
if peer_type in {"group", "channel"} and config_bool(self.config, "requireMentionInGroups", default=False):
gated = self._strip_required_mention(content)
if gated is None:
return None
content = gated
metadata = {
"chat_id": peer_id,
"message_id": message_id,
"chat_type": chat_type,
}
if media_entries:
metadata["media"] = media_entries
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _media_entries(self, data: dict[str, Any]) -> list[str]:
entries: list[str] = []
if data.get("photo"):
entries.append(compact_media_summary("photo"))
for media_type in ("document", "audio", "video"):
value = data.get(media_type)
if isinstance(value, dict):
entries.append(compact_media_summary(media_type, file_name=_string_or_none(value.get("file_name"))))
return entries
def _strip_required_mention(self, content: str) -> str | None:
username = str(self.config.get("botUsername") or "").strip().lstrip("@")
if not username:
return None
mention = f"@{username}"
if mention not in content:
return None
return content.replace(mention, "", 1).strip()
def _dm_allowed(self, identifier: str | None) -> bool:
allowlist = config_list(self.config, "allowFrom")
if not allowlist:
return True
return bool(identifier and identifier in allowlist)
def _group_allowed(self, peer_id: str | None, user_id: str | None) -> bool:
allowlist = config_list(self.config, "groupAllowFrom")
if not allowlist:
return True
return bool((peer_id and peer_id in allowlist) or (user_id and user_id in allowlist))
def _peer_type(self, chat_type: str) -> str:
if chat_type == "private":
return "dm"
if chat_type in {"group", "supergroup"}:
return "group"
if chat_type == "channel":
return "channel"
return chat_type or "unknown"
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_bot()
return self._client
def _build_bot(self) -> Any:
token = self._require_secret("botToken")
try:
from telegram import Bot
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
return Bot(token=token)
def _build_application(self) -> Any:
if self._application_factory is not None:
return self._application_factory()
token = self._require_secret("botToken")
try:
from telegram.ext import Application
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[telegram] to enable TelegramAdapter") from exc
async def handle(update: Any, context: Any) -> None:
if hasattr(update, "to_dict"):
await self.handle_update_payload(update.to_dict())
application = Application.builder().token(token).build()
try:
from telegram.ext import MessageHandler, filters
application.add_handler(MessageHandler(filters.ALL, handle))
except Exception:
pass
return application
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,180 @@
"""Weixin channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
from .base import (
allowed_by_policy,
build_inbound_message,
chunk_text,
compact_media_summary,
config_list,
mark_unclaimed,
outbound_target,
)
EventRecorder = Callable[..., None]
class WeixinAdapter:
"""Tencent iLink-style Weixin adapter with injectable client support."""
KIND = "weixin"
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str | None,
inbound_sink: ChannelInboundSink,
secrets: dict[str, Any] | None = None,
config: dict[str, Any] | None = None,
event_recorder: EventRecorder | None = None,
client: Any | None = None,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name
self.inbound_sink = inbound_sink
self.secrets = secrets or {}
self.config = config or {}
self.event_recorder = event_recorder
self._client = client
self.max_message_chars = int(self.config.get("maxMessageChars") or 2000)
async def start(self) -> None:
if self._client is not None:
return
if self.mode != "polling":
raise ValueError(f"Unsupported weixin mode: {self.mode}")
self._client = self._build_client()
async def stop(self) -> None:
close = getattr(self._client, "close", None)
if close is not None:
result = close()
if hasattr(result, "__await__"):
await result
async def handle_message_payload(self, payload: dict[str, Any]) -> None:
message = self._normalize_payload(payload)
if message is None:
return
await self.inbound_sink.accept_inbound(message)
async def send(self, message: OutboundMessage) -> None:
target = outbound_target(message)
if not target.peer_id:
mark_unclaimed(message)
return
client = self._require_client()
context_token = self._context_token(message)
for chunk in chunk_text(message.content, max_chars=self.max_message_chars):
await client.send_text(peer_id=target.peer_id, text=chunk, context_token=context_token)
def _normalize_payload(self, payload: dict[str, Any]) -> InboundMessage | None:
sender_id = _string_or_none(payload.get("from") or payload.get("from_user"))
room_id = _string_or_none(payload.get("room_id") or payload.get("roomId"))
message_id = _string_or_none(payload.get("id") or payload.get("message_id"))
message_type = str(payload.get("type") or payload.get("message_type") or "text")
if room_id:
peer_id = room_id
peer_type = "group"
user_id = sender_id
if not allowed_by_policy(
policy=self.config.get("groupPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "groupAllowFrom"),
default="disabled",
):
return None
else:
peer_id = sender_id
peer_type = "dm"
user_id = sender_id
if not allowed_by_policy(
policy=self.config.get("dmPolicy"),
identifier=peer_id,
allowlist=config_list(self.config, "allowFrom"),
default="open",
):
return None
if not peer_id:
return None
content = self._content(message_type, payload)
if not content:
return None
metadata = {
"message_id": message_id,
"message_type": message_type,
}
context_token = _string_or_none(payload.get("context_token") or payload.get("contextToken"))
if context_token:
metadata["context_token"] = context_token
if room_id:
metadata["room_id"] = room_id
return build_inbound_message(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
peer_type=peer_type,
user_id=user_id,
message_id=message_id,
content=content,
metadata=metadata,
)
def _content(self, message_type: str, payload: dict[str, Any]) -> str:
if message_type == "text":
return str(payload.get("text") or payload.get("content") or "").strip()
file_name = _string_or_none(payload.get("file_name") or payload.get("filename"))
return compact_media_summary(message_type, file_name=file_name)
def _context_token(self, message: OutboundMessage) -> str | None:
inbound_metadata = message.metadata.get("inbound_metadata")
if isinstance(inbound_metadata, dict):
value = _string_or_none(inbound_metadata.get("context_token"))
if value:
return value
return _string_or_none(message.metadata.get("context_token"))
def _require_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
return self._client
def _build_client(self) -> Any:
self._require_secret("token")
try:
import aiohttp # noqa: F401
except ImportError as exc: # pragma: no cover - optional live dependency
raise RuntimeError("Install beaver-backend[weixin] to enable WeixinAdapter") from exc
raise RuntimeError("Weixin live client is not configured for direct construction")
def _require_secret(self, key: str) -> str:
value = self.secrets.get(key)
if not value:
raise ValueError(f"{key} is required")
return str(value)
def _string_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None

View File

@ -0,0 +1,526 @@
"""Channel runtime host for adapter lifecycle and bus-first routing."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels.base import ChannelAdapter
from beaver.interfaces.channels.manager import ChannelManager
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
from beaver.services.agent_service import AgentService
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _channel_capabilities(kind: str, mode: str) -> list[str]:
if kind == "webhook":
return ["receive_text", "send_text", "sync_webhook_response"]
if kind == "terminal" and mode == "websocket":
return ["receive_text", "send_text", "persistent_connection"]
if kind in {"feishu", "qqbot", "telegram"}:
return ["receive_text", "send_text", "receive_media", "groups"]
if kind == "weixin":
return ["receive_text", "send_text", "receive_media", "direct_messages"]
return []
@dataclass(slots=True)
class ChannelAcceptResult:
accepted: bool
duplicate: bool = False
pending: bool = False
rejected: bool = False
session_id: str | None = None
dedupe_key: str | None = None
record: dict[str, Any] | None = None
error: str | None = None
class ChannelRuntime:
"""Own channel adapters, state, and the inbound/outbound bus bridge."""
def __init__(
self,
*,
service: AgentService,
workspace: Path,
channels: dict[str, ChannelConfig],
bus: MessageBus | None = None,
) -> None:
self.service = service
self.workspace = Path(workspace)
self.bus = bus or MessageBus()
self.manager = ChannelManager(self.bus)
self.channel_configs = dict(channels)
self.adapters: dict[str, ChannelAdapter] = {}
self.states: dict[str, dict[str, Any]] = {}
state_dir = self.workspace / "state" / "channels"
retention = self._default_dedupe_retention_hours()
self.dedupe = ChannelDedupeStore(state_dir / "dedupe.json", retention_hours=retention)
self.events = ChannelEventLog(state_dir / "events.jsonl")
self._bridge_task: asyncio.Task[None] | None = None
self._dispatch_task: asyncio.Task[None] | None = None
self._stop_event = asyncio.Event()
self._dispatch_stop_event = asyncio.Event()
self._lifecycle_lock = asyncio.Lock()
async def start(self) -> None:
self._stop_event.clear()
self._dispatch_stop_event.clear()
for channel_id, cfg in self.channel_configs.items():
if not cfg.enabled:
self.states[channel_id] = {"state": "disabled", "last_error": None}
continue
try:
adapter = self._build_adapter(channel_id, cfg)
self.adapters[channel_id] = adapter
self.manager.register(adapter)
await adapter.start()
self.states[channel_id] = {
"state": "running",
"last_error": None,
"started_at": _iso_now(),
}
self.events.record(channel_id=channel_id, kind="adapter_started")
except Exception as exc: # pragma: no cover - defensive startup isolation
self.states[channel_id] = {"state": "error", "last_error": str(exc)}
self.events.record(
channel_id=channel_id,
kind="adapter_error",
status="error",
error=str(exc),
)
self._bridge_task = asyncio.create_task(self._bridge_inbound_to_agent())
self._dispatch_task = asyncio.create_task(
self.manager.dispatch_outbound(
self._dispatch_stop_event,
on_delivered=self._record_outbound_delivered,
on_failed=self._record_outbound_failed,
)
)
async def stop(self) -> None:
self._stop_event.set()
if self._bridge_task is not None:
self._bridge_task.cancel()
try:
await self._bridge_task
except asyncio.CancelledError:
pass
self._dispatch_stop_event.set()
if self._dispatch_task is not None:
try:
await asyncio.wait_for(self._dispatch_task, timeout=1.0)
except asyncio.TimeoutError:
self._dispatch_task.cancel()
try:
await self._dispatch_task
except asyncio.CancelledError:
pass
await self.manager.stop()
for channel_id in self.adapters:
self.events.record(channel_id=channel_id, kind="adapter_stopped")
async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
async with self._lifecycle_lock:
current = self.channel_configs.get(channel_id)
if current == config and channel_id in self.adapters:
return
if not config.enabled:
await self._remove_channel_locked(channel_id)
self.channel_configs[channel_id] = config
self.states[channel_id] = {"state": "disabled", "last_error": None}
return
adapter = self._build_adapter(channel_id, config)
await adapter.start()
old_adapter = self.adapters.get(channel_id)
self.manager.replace_registered(adapter)
self.adapters[channel_id] = adapter
self.channel_configs[channel_id] = config
self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()}
self.events.record(channel_id=channel_id, kind="adapter_started")
if old_adapter is not None and old_adapter is not adapter:
await old_adapter.stop()
async def remove_channel(self, channel_id: str) -> None:
async with self._lifecycle_lock:
await self._remove_channel_locked(channel_id)
async def _remove_channel_locked(self, channel_id: str) -> None:
adapter = self.adapters.pop(channel_id, None)
self.manager.unregister(channel_id)
self.channel_configs.pop(channel_id, None)
if adapter is not None:
await adapter.stop()
self.events.record(channel_id=channel_id, kind="adapter_stopped")
self.states[channel_id] = {"state": "removed", "last_error": None}
async def accept_inbound(self, message: InboundMessage) -> ChannelAcceptResult:
identity = message.channel_identity
if identity is None:
self.events.record(
channel_id=message.channel,
kind="inbound_rejected",
status="error",
error="channel_identity is required",
)
return ChannelAcceptResult(
accepted=False,
rejected=True,
error="channel_identity is required",
)
validation_error = identity.validation_error()
if validation_error:
self.events.record(
channel_id=identity.channel_id,
kind="inbound_rejected",
status="error",
error=validation_error,
)
return ChannelAcceptResult(accepted=False, rejected=True, error=validation_error)
expected_session_id = identity.session_id()
if message.session_id != expected_session_id:
self.events.record(
channel_id=identity.channel_id,
kind="session_id_normalized",
session_id=expected_session_id,
message_id=identity.message_id,
)
message.session_id = expected_session_id
message.channel = identity.channel_id
dedupe_key = identity.dedupe_key()
if dedupe_key:
write = self.dedupe.mark_processing(
dedupe_key=dedupe_key,
session_id=expected_session_id,
message_id=identity.message_id or "",
)
if not write.created:
record = write.record or {}
self.events.record(
channel_id=identity.channel_id,
kind="inbound_duplicate",
session_id=expected_session_id,
message_id=identity.message_id,
status=str(record.get("status") or "processing"),
)
return ChannelAcceptResult(
accepted=False,
duplicate=True,
pending=record.get("status") == "processing",
session_id=expected_session_id,
dedupe_key=dedupe_key,
record=record,
)
self.events.record(
channel_id=identity.channel_id,
kind="inbound_accepted",
session_id=expected_session_id,
message_id=identity.message_id,
text=message.content,
)
await self.bus.publish_inbound(message)
return ChannelAcceptResult(
accepted=True,
session_id=expected_session_id,
dedupe_key=dedupe_key,
)
def statuses(self) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
recent = self.events.recent(limit=500)
last_by_channel = {event["channel_id"]: event for event in recent if event.get("channel_id")}
for channel_id, cfg in self.channel_configs.items():
state = self.states.get(channel_id, {"state": "configured", "last_error": None})
capabilities = _channel_capabilities(cfg.kind, cfg.mode)
webhook_url = None
websocket_url = None
connected_peers = 0
if cfg.kind == "webhook":
webhook_url = f"/api/channels/{channel_id}/webhook"
elif cfg.kind == "terminal" and cfg.mode == "websocket":
websocket_url = f"/api/channels/{channel_id}/ws"
adapter = self.adapters.get(channel_id)
if adapter is not None and hasattr(adapter, "status_extra"):
extra = adapter.status_extra() # type: ignore[attr-defined]
connected_peers = int(extra.get("connected_peers") or 0)
items.append(
{
"channel_id": channel_id,
"name": channel_id,
"kind": cfg.kind,
"mode": cfg.mode,
"display_name": cfg.display_name or channel_id,
"enabled": cfg.enabled,
"state": state.get("state", "configured"),
"account_id": cfg.account_id,
"last_error": state.get("last_error"),
"started_at": state.get("started_at"),
"last_event_at": last_by_channel.get(channel_id, {}).get("created_at"),
"capabilities": capabilities,
"webhook_url": webhook_url,
"websocket_url": websocket_url,
"connected_peers": connected_peers,
}
)
return items
def recent_events(self, channel_id: str, *, limit: int = 100) -> list[dict[str, Any]]:
return self.events.recent(channel_id=channel_id, limit=limit)
def record_event(
self,
*,
channel_id: str,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
run_id: str | None = None,
status: str = "ok",
error: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
self.events.record(
channel_id=channel_id,
kind=kind,
session_id=session_id,
message_id=message_id,
run_id=run_id,
status=status,
error=error,
metadata=metadata,
)
def _build_adapter(self, channel_id: str, cfg: ChannelConfig) -> ChannelAdapter:
if cfg.kind == "webhook" and cfg.mode == "webhook":
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
return GenericWebhookAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
response_timeout_seconds=float(cfg.config.get("response_timeout_seconds") or 1800),
)
if cfg.kind == "terminal" and cfg.mode == "websocket":
from beaver.interfaces.channels.terminal_websocket import TerminalWebSocketAdapter
return TerminalWebSocketAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
event_recorder=self.record_event,
heartbeat_seconds=float(cfg.config.get("heartbeat_seconds") or 30),
max_message_chars=int(cfg.config.get("max_message_chars") or 20000),
)
if cfg.kind == "telegram" and cfg.mode in {"polling", "webhook"}:
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
return TelegramAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "feishu" and cfg.mode in {"websocket", "webhook"}:
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
return FeishuAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "qqbot" and cfg.mode == "websocket":
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
return QQBotAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "weixin" and cfg.mode == "polling":
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
return WeixinAdapter(
channel_id=channel_id,
kind=cfg.kind,
mode=cfg.mode,
account_id=cfg.account_id,
display_name=cfg.display_name,
inbound_sink=self,
secrets=cfg.secrets,
config=cfg.config,
event_recorder=self.record_event,
)
if cfg.kind == "external_connector" and cfg.mode == "http":
import os
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel
base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip()
token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
platform_kind = str(cfg.config.get("platformKind") or "").strip()
connection_id = str(cfg.config.get("connectionId") or "").strip()
if not base_url:
raise ValueError("external connector sidecarBaseUrl is required")
if not platform_kind:
raise ValueError("external connector platformKind is required")
if not connection_id:
raise ValueError("external connector connectionId is required")
return ExternalConnectorChannel(
channel_id=channel_id,
platform_kind=platform_kind,
connection_id=connection_id,
account_id=cfg.account_id,
display_name=cfg.display_name,
sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token),
)
raise ValueError(f"Unsupported channel kind/mode: {cfg.kind}/{cfg.mode}")
async def _bridge_inbound_to_agent(self) -> None:
current_inbound: InboundMessage | None = None
while not self._stop_event.is_set():
try:
current_inbound = await asyncio.wait_for(self.bus.consume_inbound(), timeout=0.25)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
inbound = current_inbound
identity = inbound.channel_identity
try:
self.events.record(
channel_id=inbound.channel,
kind="direct_run_started",
session_id=inbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
)
outbound = await self.service.handle_inbound_message(inbound)
except asyncio.CancelledError:
outbound = AgentService.build_outbound_error(
inbound,
detail="Channel runtime stopped before completing the inbound message",
finish_reason="cancelled",
)
self._mark_dedupe_result(inbound, outbound)
await self.bus.publish_outbound(outbound)
current_inbound = None
raise
except Exception as exc:
self.events.record(
channel_id=inbound.channel,
kind="direct_run_failed",
session_id=inbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
status="error",
error=str(exc),
)
outbound = AgentService.build_outbound_error(
inbound,
detail=str(exc),
finish_reason="error",
)
else:
self.events.record(
channel_id=outbound.channel,
kind="direct_run_finished",
session_id=outbound.session_id,
message_id=identity.message_id if identity else inbound.message_id,
run_id=outbound.run_id,
)
self._mark_dedupe_result(inbound, outbound)
await self.bus.publish_outbound(outbound)
current_inbound = None
def _mark_dedupe_result(self, inbound: InboundMessage, outbound: OutboundMessage) -> None:
identity = inbound.channel_identity
dedupe_key = identity.dedupe_key() if identity else None
if not dedupe_key:
return
cfg = self.channel_configs.get(identity.channel_id)
max_reply_chars = int((cfg.config if cfg else {}).get("max_cached_reply_chars") or 20000)
max_error_chars = int((cfg.config if cfg else {}).get("max_cached_error_chars") or 4000)
if outbound.finish_reason == "error":
self.dedupe.mark_error(
dedupe_key=dedupe_key,
error=outbound.content,
max_error_chars=max_error_chars,
)
else:
self.dedupe.mark_done(
dedupe_key=dedupe_key,
run_id=outbound.run_id,
reply=outbound.content,
max_reply_chars=max_reply_chars,
)
async def _record_outbound_delivered(self, message: OutboundMessage) -> None:
kind = "outbound_unclaimed" if message.metadata.get("delivery_status") == "unclaimed" else "outbound_delivered"
self.events.record(
channel_id=message.channel,
kind=kind,
session_id=message.session_id,
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
run_id=message.run_id,
)
async def _record_outbound_failed(self, message: OutboundMessage, exc: Exception | None) -> None:
self.events.record(
channel_id=message.channel,
kind="outbound_delivery_failed",
session_id=message.session_id,
message_id=message.channel_identity.message_id if message.channel_identity else message.message_id,
run_id=message.run_id,
status="error",
error=str(exc) if exc else "channel not registered",
)
def _default_dedupe_retention_hours(self) -> int:
for cfg in self.channel_configs.values():
value = cfg.config.get("dedupe_retention_hours")
if value is not None:
return int(value)
return 48

View File

@ -0,0 +1,198 @@
"""Persistent channel runtime state."""
from __future__ import annotations
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
def _now_ms() -> int:
return int(time.time() * 1000)
def _iso_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(slots=True)
class DedupeWriteResult:
created: bool
record: dict[str, Any] | None = None
class ChannelDedupeStore:
def __init__(self, path: Path, *, retention_hours: int = 48) -> None:
self.path = path
self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000
self._lock = Lock()
def get(self, dedupe_key: str) -> dict[str, Any] | None:
with self._lock:
data = self._load()
self._prune_unlocked(data, _now_ms())
record = data["records"].get(dedupe_key)
self._save(data)
return record
def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult:
with self._lock:
data = self._load()
now_ms = _now_ms()
self._prune_unlocked(data, now_ms)
existing = data["records"].get(dedupe_key)
if existing is not None:
self._save(data)
return DedupeWriteResult(created=False, record=existing)
record = {
"dedupe_key": dedupe_key,
"status": "processing",
"session_id": session_id,
"message_id": message_id,
"run_id": None,
"reply": None,
"error": None,
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
data["records"][dedupe_key] = record
self._save(data)
return DedupeWriteResult(created=True, record=record)
def mark_done(
self,
*,
dedupe_key: str,
run_id: str | None,
reply: str,
max_reply_chars: int,
) -> None:
self._mark_result(
dedupe_key=dedupe_key,
status="done",
run_id=run_id,
reply=reply[: max(0, int(max_reply_chars))],
error=None,
)
def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None:
self._mark_result(
dedupe_key=dedupe_key,
status="error",
run_id=None,
reply=None,
error=error[: max(0, int(max_error_chars))],
)
def _mark_result(
self,
*,
dedupe_key: str,
status: str,
run_id: str | None,
reply: str | None,
error: str | None,
) -> None:
with self._lock:
data = self._load()
record = data["records"].get(dedupe_key)
if record is None:
record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()}
data["records"][dedupe_key] = record
record.update(
{
"status": status,
"run_id": run_id,
"reply": reply,
"error": error,
"updated_at_ms": _now_ms(),
}
)
self._save(data)
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return {"records": {}}
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"records": {}}
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
return {"records": {}}
return data
def _save(self, data: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None:
records = data.get("records", {})
expired_before = now_ms - self.retention_ms
for key, record in list(records.items()):
updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0)
if updated_at_ms < expired_before:
records.pop(key, None)
class ChannelEventLog:
def __init__(self, path: Path) -> None:
self.path = path
self._lock = Lock()
def record(
self,
*,
channel_id: str,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
run_id: str | None = None,
status: str = "ok",
error: str | None = None,
text: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
entry = {
"event_id": uuid4().hex,
"channel_id": channel_id,
"kind": kind,
"session_id": session_id,
"message_id": message_id,
"run_id": run_id,
"status": status,
"error": error,
"text_preview": (text or "")[:120] if text else None,
"text_length": len(text) if text else 0,
"metadata": metadata or {},
"created_at": _iso_now(),
}
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
return entry
def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
if not self.path.exists():
return []
lines = self.path.read_text(encoding="utf-8").splitlines()
items: list[dict[str, Any]] = []
for line in reversed(lines):
try:
item = json.loads(line)
except json.JSONDecodeError:
continue
if channel_id and item.get("channel_id") != channel_id:
continue
items.append(item)
if len(items) >= max(1, int(limit)):
break
return list(reversed(items))

View File

@ -0,0 +1,301 @@
"""Text-only terminal WebSocket channel adapter."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.interfaces.channels.base import ChannelInboundSink
try:
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
except ModuleNotFoundError: # pragma: no cover - import-only fallback
class WebSocketDisconnect(Exception):
"""Fallback disconnect exception for skeleton import environments."""
class WebSocket: # type: ignore[override]
"""Fallback websocket annotation shim."""
def _clean(value: Any) -> str:
return str(value or "").strip()
@dataclass(slots=True)
class TerminalConnection:
websocket: WebSocket
peer_id: str
session_id: str
thread_id: str | None = None
user_id: str | None = None
device_name: str = ""
capabilities: list[str] = field(default_factory=list)
class TerminalWebSocketAdapter:
"""Accept text terminal websocket frames and deliver final assistant replies."""
def __init__(
self,
*,
channel_id: str,
kind: str,
mode: str,
account_id: str,
display_name: str = "",
inbound_sink: ChannelInboundSink,
event_recorder: Callable[..., None] | None = None,
heartbeat_seconds: float = 30,
max_message_chars: int = 20000,
) -> None:
self.channel_id = channel_id
self.kind = kind
self.mode = mode
self.account_id = account_id
self.display_name = display_name or channel_id
self.inbound_sink = inbound_sink
self.event_recorder = event_recorder
self.heartbeat_seconds = max(1.0, float(heartbeat_seconds))
self.max_message_chars = max(1, int(max_message_chars))
self.started = False
self._connections_by_session: dict[str, TerminalConnection] = {}
self._session_by_peer: dict[str, str] = {}
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
for connection in list(self._connections_by_session.values()):
with suppress(Exception):
await connection.websocket.close(code=1001)
self._connections_by_session.clear()
self._session_by_peer.clear()
def status_extra(self) -> dict[str, Any]:
return {"connected_peers": len(self._connections_by_session)}
async def handle_websocket(self, websocket: WebSocket) -> None:
await websocket.accept()
connection: TerminalConnection | None = None
try:
while True:
try:
payload = await websocket.receive_json()
except WebSocketDisconnect:
break
except ValueError:
await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
continue
if not isinstance(payload, dict):
await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
continue
frame_type = _clean(payload.get("type")).lower()
if frame_type == "ping":
await websocket.send_json({"type": "pong"})
continue
if frame_type == "connect":
connection = await self._handle_connect(websocket, payload, current=connection)
continue
if frame_type == "message":
if connection is None:
await websocket.send_json({"type": "error", "error": "connect is required before message"})
continue
await self._handle_message(websocket, connection, payload)
continue
await websocket.send_json(
{
"type": "error",
"error": f"Unsupported websocket frame type: {frame_type or '<empty>'}",
}
)
finally:
if connection is not None:
self._remove_connection(connection)
self._record(
kind="terminal_disconnected",
session_id=connection.session_id,
metadata={"peer_id": connection.peer_id, "device_name": connection.device_name},
)
async def _handle_connect(
self,
websocket: WebSocket,
payload: dict[str, Any],
*,
current: TerminalConnection | None,
) -> TerminalConnection | None:
peer_id = _clean(payload.get("peer_id"))
if not peer_id:
await websocket.send_json({"type": "error", "error": "peer_id is required"})
return current
thread_id = _clean(payload.get("thread_id")) or None
user_id = _clean(payload.get("user_id")) or None
device_name = _clean(payload.get("device_name"))
capabilities = [str(item) for item in payload.get("capabilities") or [] if item is not None]
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=peer_id,
thread_id=thread_id,
peer_type="terminal",
user_id=user_id,
)
session_id = identity.session_id()
connection = TerminalConnection(
websocket=websocket,
peer_id=peer_id,
session_id=session_id,
thread_id=thread_id,
user_id=user_id,
device_name=device_name,
capabilities=capabilities,
)
if current is not None and current.session_id != session_id:
self._remove_connection(current)
old = self._connections_by_session.get(session_id)
if old is not None and old.websocket is not websocket:
with suppress(Exception):
await old.websocket.close(code=1000)
self._connections_by_session[session_id] = connection
self._session_by_peer[peer_id] = session_id
self._record(
kind="terminal_connected",
session_id=session_id,
metadata={"peer_id": peer_id, "device_name": device_name, "capabilities": capabilities},
)
await websocket.send_json(
{
"type": "connected",
"channel_id": self.channel_id,
"session_id": session_id,
}
)
return connection
async def _handle_message(
self,
websocket: WebSocket,
connection: TerminalConnection,
payload: dict[str, Any],
) -> None:
message_id = _clean(payload.get("message_id"))
text = _clean(payload.get("text"))
if not message_id:
await websocket.send_json({"type": "error", "error": "message_id is required"})
return
if not text:
await websocket.send_json({"type": "error", "error": "text is required"})
return
if len(text) > self.max_message_chars:
await websocket.send_json(
{
"type": "error",
"error": f"text exceeds max_message_chars ({self.max_message_chars})",
}
)
return
thread_id = _clean(payload.get("thread_id")) or connection.thread_id
user_id = _clean(payload.get("user_id")) or connection.user_id
identity = ChannelIdentity(
channel_id=self.channel_id,
kind=self.kind,
account_id=self.account_id,
peer_id=connection.peer_id,
thread_id=thread_id,
peer_type="terminal",
user_id=user_id,
message_id=message_id,
)
inbound = InboundMessage(
channel=self.channel_id,
content=text,
content_type="text",
user_id=user_id,
channel_identity=identity,
metadata={
"terminal": {
"peer_id": connection.peer_id,
"device_name": connection.device_name,
"capabilities": connection.capabilities,
}
},
)
accept = await self.inbound_sink.accept_inbound(inbound)
ack: dict[str, Any] = {
"type": "ack",
"message_id": message_id,
"session_id": accept.session_id or identity.session_id(),
"accepted": accept.accepted,
}
if accept.duplicate:
ack["duplicate"] = True
ack["pending"] = accept.pending
record = accept.record or {}
if record.get("reply"):
ack["reply"] = record["reply"]
if accept.error or record.get("error"):
ack["error"] = accept.error or record.get("error")
await websocket.send_json(ack)
async def send(self, message: OutboundMessage) -> None:
session_id = message.session_id
if not session_id and message.channel_identity is not None:
session_id = message.channel_identity.session_id()
connection = self._connections_by_session.get(session_id or "")
if connection is None:
message.metadata["delivery_status"] = "unclaimed"
return
payload = {
"type": "message",
"role": "assistant",
"message_id": message.channel_identity.message_id if message.channel_identity else message.message_id,
"run_id": message.run_id,
"text": message.content,
"finish_reason": message.finish_reason,
}
try:
await connection.websocket.send_json(payload)
except Exception:
message.metadata["delivery_status"] = "unclaimed"
self._remove_connection(connection)
def _remove_connection(self, connection: TerminalConnection) -> None:
current = self._connections_by_session.get(connection.session_id)
if current is connection:
self._connections_by_session.pop(connection.session_id, None)
if self._session_by_peer.get(connection.peer_id) == connection.session_id:
self._session_by_peer.pop(connection.peer_id, None)
def _record(
self,
*,
kind: str,
session_id: str | None = None,
message_id: str | None = None,
status: str = "ok",
error: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
if self.event_recorder is None:
return
self.event_recorder(
channel_id=self.channel_id,
kind=kind,
session_id=session_id,
message_id=message_id,
status=status,
error=error,
metadata=metadata,
)

View File

@ -19,6 +19,18 @@ from typing import Any
from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config
from beaver.foundation.events import ChannelIdentity, InboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
ConnectorSidecarClient,
CredentialStore,
FeishuConnector,
MessageDedupeStore,
TelegramConnector,
WeixinConnector,
)
from beaver.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
@ -44,11 +56,25 @@ from .files import (
workspace_file_path,
)
from .schemas import (
WebChatAcceptanceRequest,
WebChatAcceptanceResponse,
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebStatusResponse,
@ -56,7 +82,7 @@ from .schemas import (
try:
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from fastapi.responses import JSONResponse, Response
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
def File(default: Any = None) -> Any: # type: ignore[override]
return default
@ -90,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
self.media_type = media_type
self.headers = headers or {}
class JSONResponse(Response): # type: ignore[override]
def __init__(self, content: Any, status_code: int = 200) -> None:
super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json")
self.status_code = status_code
class WebSocketDisconnect(Exception):
"""Fallback websocket disconnect exception."""
@ -155,6 +186,13 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
return decorator
RAW_TOOL_CALL_DISPLAY_FALLBACK = (
"The run reached the configured tool-call limit before producing a reliable final answer. "
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
"Please request a revision to continue the task."
)
@asynccontextmanager
async def _app_lifespan(
app: FastAPI,
@ -172,7 +210,9 @@ async def _app_lifespan(
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
app.state.agent_service = attached_service
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
app.state.channel_runtime = None
started = False
channel_runtime: ChannelRuntime | None = None
if owns_service:
try:
await attached_service.start()
@ -189,6 +229,29 @@ async def _app_lifespan(
else:
attached_service.close()
raise
try:
loaded = attached_service.create_loop().boot()
app.state.channel_connection_workspace = loaded.workspace
connector_registry = _build_channel_connector_registry(loaded.workspace)
app.state.channel_connector_registry = connector_registry
connection_channels = await connector_registry.materialize_channel_configs()
runtime_channels = dict(loaded.config.channels)
runtime_channels.update(connection_channels)
channel_runtime = ChannelRuntime(
service=attached_service,
workspace=loaded.workspace,
channels=runtime_channels,
)
app.state.channel_runtime = channel_runtime
await channel_runtime.start()
except BaseException:
if owns_service and started:
with suppress(BaseException):
await attached_service.shutdown(
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
raise
worker: SkillLearningWorker | None = None
worker_task = None
worker_config = SkillLearningWorkerConfig.from_env()
@ -205,6 +268,10 @@ async def _app_lifespan(
try:
yield
finally:
runtime = getattr(app.state, "channel_runtime", None)
if isinstance(runtime, ChannelRuntime):
with suppress(BaseException):
await runtime.stop()
cron_service = getattr(app.state, "cron_service", None)
if isinstance(cron_service, CronService):
cron_service.stop()
@ -272,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
return service
def get_channel_runtime(request: Request) -> ChannelRuntime:
runtime = getattr(request.app.state, "channel_runtime", None)
if not isinstance(runtime, ChannelRuntime):
raise HTTPException(status_code=503, detail="Channel runtime is not running")
return runtime
def _connection_state_dir(workspace: Path) -> Path:
return Path(workspace) / "state" / "channel_connections"
def _channel_connection_workspace(request: Request) -> Path:
workspace = getattr(request.app.state, "channel_connection_workspace", None)
if workspace is not None:
return Path(workspace)
return Path(get_agent_service(request).loader.workspace)
def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")
def _bridge_token() -> str:
return os.getenv("BEAVER_BRIDGE_TOKEN", "")
def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
state_dir = _connection_state_dir(workspace)
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
)
)
sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
registry.register(
WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=sidecar_client,
sidecar_base_url=sidecar_base_url,
)
)
registry.register(
FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=sidecar_client,
sidecar_base_url=sidecar_base_url,
)
)
return registry
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
registry = getattr(request.app.state, "channel_connector_registry", None)
if isinstance(registry, ChannelConnectorRegistry):
return registry
workspace = getattr(request.app.state, "channel_connection_workspace", None)
if workspace is None:
raise RuntimeError("Channel connector registry unavailable before service boot")
registry = _build_channel_connector_registry(workspace)
request.app.state.channel_connector_registry = registry
return registry
def _connection_response_view(connection: Any) -> dict[str, Any]:
view = connection.to_dict()
view.pop("credentials_ref", None)
view.pop("connector_ref", None)
view.pop("pairing_session_id", None)
return view
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(config, dict):
return {}
return {
_camel_to_snake_text(str(key)): value
for key, value in config.items()
if str(key).strip()
}
def _camel_to_snake_text(value: str) -> str:
result: list[str] = []
for char in value.strip():
if char.isupper() and result:
result.append("_")
result.append(char.lower())
return "".join(result)
def _self_restart_enabled() -> bool:
return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"}
def _schedule_self_restart(delay_seconds: float = 0.75) -> None:
import threading
def _exit_later() -> None:
time.sleep(delay_seconds)
os._exit(0)
threading.Thread(target=_exit_later, daemon=True).start()
def create_app(
*,
workspace: str | Path | None = None,
@ -365,13 +544,334 @@ def create_app(
"workspace_exists": loaded.workspace.exists(),
"model": config.default_model or agent_service.profile.default_model,
"max_tokens": agent_service.profile.max_tokens,
"max_context_messages": agent_service.profile.max_context_messages,
"temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status,
"channels": [{"name": "web", "enabled": True}],
"channels": get_channel_runtime(request).statuses(),
"runtime_controls": {"self_restart": _self_restart_enabled()},
"cron": cron_service.status(),
}
@app.get("/api/channels")
async def list_channels(request: Request) -> list[dict[str, Any]]:
return get_channel_runtime(request).statuses()
@app.get("/api/channel-connectors")
async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
return get_channel_connector_registry(request).connectors()
@app.get("/api/channel-connections")
async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
registry = get_channel_connector_registry(request)
return [_connection_response_view(connection) for connection in registry.connection_store.list()]
@app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
async def create_channel_connection(
request: Request,
payload: WebChannelConnectionCreateRequest,
) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
kind = _clean_text(payload.kind)
mode = _clean_text(payload.mode)
if not kind:
raise HTTPException(status_code=400, detail="Connection kind is required")
if not mode:
raise HTTPException(status_code=400, detail="Connection mode is required")
secrets_payload = payload.secrets or {}
secrets = {key: value for key, value in secrets_payload.items() if value}
credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
connection = registry.connection_store.create(
kind=kind,
mode=mode,
display_name=_clean_text(payload.display_name) or kind,
account_id=_clean_text(payload.account_id) or "",
owner_user_id=_clean_text(payload.owner_user_id) or None,
auth_type=_clean_text(payload.auth_type) or "token",
credentials_ref=credentials_ref,
runtime_config=_normalize_connection_config(payload.config),
)
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(credentials_ref),
)
@app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
async def update_channel_connection(
connection_id: str,
request: Request,
payload: WebChannelConnectionUpdateRequest,
) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if payload.display_name is not None:
connection.display_name = _clean_text(payload.display_name) or connection.display_name
if payload.account_id is not None:
connection.account_id = _clean_text(payload.account_id) or connection.account_id
if payload.config is not None:
connection.runtime_config = _normalize_connection_config(payload.config)
if payload.secrets:
secrets = {key: value for key, value in payload.secrets.items() if value}
if secrets:
# TODO: add credential GC when connection updates credentials.
connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
connection = registry.connection_store.update(connection)
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(connection.credentials_ref),
)
@app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelConnectionResponse(
connection=_connection_response_view(connection),
credentials=registry.credential_store.redacted(connection.credentials_ref),
)
@app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
registry = get_channel_connector_registry(request)
try:
result = await registry.validate(connection_id)
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelValidationResponse(
ok=result.ok,
status=result.status,
account_id=result.account_id,
display_name=result.display_name,
error=result.error,
metadata=result.metadata,
connection=_connection_response_view(connection),
)
@app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
registry = get_channel_connector_registry(request)
try:
await registry.revoke(connection_id)
connection = registry.connection_store.get(connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})
@app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
async def start_channel_connector_session(
request: Request,
payload: WebConnectorSessionCreateRequest,
) -> WebConnectorSessionResponse:
registry = get_channel_connector_registry(request)
kind = _clean_text(payload.kind)
try:
connector = registry.connector_for_kind(kind)
except KeyError:
raise HTTPException(status_code=404, detail="Connector not found")
start_session = getattr(connector, "start_session", None)
if start_session is None:
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await start_session(
display_name=_clean_text(payload.display_name) or kind,
owner_user_id=_clean_text(payload.owner_user_id) or None,
options=payload.options,
)
connection_id = _clean_text(view.get("connectionId"))
connection_view = None
if connection_id:
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
return WebConnectorSessionResponse(session=view, connection=connection_view)
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
registry = get_channel_connector_registry(request)
connection = next(
(item for item in registry.connection_store.list() if item.pairing_session_id == session_id),
None,
)
if connection is None:
raise HTTPException(status_code=404, detail="Connector session not found")
connector = registry.connector_for_kind(connection.kind)
poll_session = getattr(connector, "poll_session", None)
if poll_session is None:
raise HTTPException(status_code=400, detail="Connector does not support sessions")
view = await poll_session(session_id)
connection = registry.connection_store.get(connection.connection_id)
if connection.status == "connected":
runtime = get_channel_runtime(request)
config = (await registry.materialize_channel_configs())[connection.channel_id]
await runtime.add_channel(connection.channel_id, config)
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
async def accept_connector_bridge_event(
request: Request,
payload: WebConnectorBridgeEventRequest,
authorization: str | None = Header(default=None),
) -> Any:
expected = _bridge_token()
if not expected or authorization != f"Bearer {expected}":
raise HTTPException(status_code=401, detail="Invalid connector bridge token")
registry = get_channel_connector_registry(request)
try:
connection = registry.connection_store.get(payload.connection_id)
except KeyError:
raise HTTPException(status_code=404, detail="Channel connection not found")
if connection.status == "revoked":
raise HTTPException(status_code=404, detail="Channel connection not found")
store = _message_dedupe_store(_channel_connection_workspace(request))
begin = store.begin(
connection_id=payload.connection_id,
event_id=payload.event_id,
delivery_attempt=payload.delivery_attempt,
)
if not begin.should_process:
body = WebConnectorBridgeEventResponse(
accepted=begin.http_status == 200,
duplicate=True,
pending=begin.http_status == 409,
retryAfterSeconds=begin.retry_after_seconds,
).model_dump(by_alias=True)
return JSONResponse(status_code=begin.http_status, content=body)
runtime = get_channel_runtime(request)
identity = ChannelIdentity(
channel_id=payload.channel_id,
kind=payload.kind,
account_id=payload.account_id,
peer_id=payload.peer_id,
thread_id=payload.thread_id,
peer_type=payload.peer_type,
user_id=payload.user_id,
message_id=payload.message_id,
)
inbound = InboundMessage(
channel=payload.channel_id,
content=payload.content,
content_type=payload.message_type,
channel_identity=identity,
user_id=payload.user_id,
message_id=payload.message_id,
metadata=dict(payload.metadata),
)
result = await runtime.accept_inbound(inbound)
if result.accepted or result.duplicate:
store.complete(begin.dedupe_key, message_id=payload.message_id)
else:
store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
return WebConnectorBridgeEventResponse(
accepted=result.accepted,
duplicate=result.duplicate,
pending=result.pending,
)
@app.get("/api/channels/{channel_id}/config")
async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
channel = _ensure_dict(raw, "channels").get(channel_id)
if not isinstance(channel, dict):
raise HTTPException(status_code=404, detail="Channel not found")
return _channel_config_view(channel_id, channel)
@app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse)
async def update_channel_config(
channel_id: str,
request: Request,
payload: WebChannelConfigRequest,
) -> WebChannelConfigResponse:
if not _clean_text(channel_id):
raise HTTPException(status_code=400, detail="Channel id is required")
kind = _clean_text(payload.kind)
mode = _clean_text(payload.mode)
if not kind:
raise HTTPException(status_code=400, detail="Channel kind is required")
if not mode:
raise HTTPException(status_code=400, detail="Channel mode is required")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
channels = _ensure_dict(raw, "channels")
current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {}
current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {}
next_secrets = dict(current_secrets)
for key, value in (payload.secrets or {}).items():
cleaned_key = _clean_text(key)
cleaned_value = _clean_text(value)
if not cleaned_key or not cleaned_value:
continue
next_secrets[cleaned_key] = cleaned_value
channel_payload: dict[str, Any] = {
"enabled": bool(payload.enabled),
"kind": kind,
"mode": mode,
"accountId": _clean_text(payload.account_id) or "",
"displayName": _clean_text(payload.display_name) or channel_id,
"config": payload.config or {},
"secrets": next_secrets,
}
channels[channel_id] = channel_payload
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebChannelConfigResponse(
ok=True,
channel_id=channel_id,
restart_required=True,
channel=_channel_config_view(channel_id, channel_payload),
)
@app.get("/api/channels/{channel_id}/events")
async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]:
return get_channel_runtime(request).recent_events(channel_id, limit=limit)
@app.post("/api/channels/{channel_id}/webhook")
async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse:
runtime = get_channel_runtime(request)
adapter = runtime.adapters.get(channel_id)
if adapter is None or not hasattr(adapter, "handle_webhook_payload"):
raise HTTPException(status_code=404, detail="Webhook channel not found")
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object")
result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined]
status_code = 202 if result.get("pending") else 200
return JSONResponse(result, status_code=status_code)
@app.websocket("/api/channels/{channel_id}/ws")
async def channel_websocket(websocket: WebSocket, channel_id: str) -> None:
runtime = getattr(websocket.app.state, "channel_runtime", None)
if not isinstance(runtime, ChannelRuntime):
await websocket.accept()
await websocket.send_json({"type": "error", "error": "Channel runtime is not running"})
await websocket.close(code=1011)
return
adapter = runtime.adapters.get(channel_id)
if adapter is None or not hasattr(adapter, "handle_websocket"):
await websocket.accept()
await websocket.send_json({"type": "error", "error": "WebSocket channel not found"})
await websocket.close(code=1008)
return
await adapter.handle_websocket(websocket) # type: ignore[attr-defined]
@app.post("/api/runtime/restart")
async def restart_runtime() -> JSONResponse:
if not _self_restart_enabled():
raise HTTPException(status_code=403, detail="Self restart is disabled")
_schedule_self_restart()
return JSONResponse({"ok": True, "restarting": True}, status_code=202)
@app.post("/api/auth/login")
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
username = _clean_text(payload.get("username"))
@ -585,6 +1085,38 @@ def create_app(
_reload_agent_config(agent_service, config_path)
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
@app.post("/api/agent-config", response_model=WebAgentConfigResponse)
async def update_agent_config(
request: Request,
payload: WebAgentConfigRequest,
) -> WebAgentConfigResponse:
if payload.max_tokens is not None and payload.max_tokens <= 0:
raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
if payload.temperature < 0 or payload.temperature > 2:
raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
if payload.max_tool_iterations < 0:
raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
agents = _ensure_dict(raw, "agents")
defaults = _ensure_dict(agents, "defaults")
if payload.max_tokens is None:
defaults.pop("maxTokens", None)
defaults.pop("max_tokens", None)
else:
defaults["maxTokens"] = payload.max_tokens
defaults.pop("max_tokens", None)
defaults["temperature"] = payload.temperature
defaults["maxToolIterations"] = payload.max_tool_iterations
defaults.pop("max_tool_iterations", None)
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebAgentConfigResponse(ok=True)
@app.get("/api/sessions")
async def list_sessions(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
@ -1719,7 +2251,8 @@ def create_app(
usage=result.usage,
task_id=result.task_id,
task_status=result.task_status,
validation_result=result.validation_result,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
fallback_target = _model_dump(payload.fallback_target)
@ -1769,7 +2302,8 @@ def create_app(
usage=result.usage,
task_id=result.task_id,
task_status=result.task_status,
validation_result=result.validation_result,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
@app.websocket("/ws/{session_id:path}")
@ -1882,6 +2416,30 @@ def create_app(
}
)
@app.post(
"/api/chat/acceptance",
response_model=WebChatAcceptanceResponse,
responses={
400: {"model": WebErrorResponse},
404: {"model": WebErrorResponse},
},
)
async def chat_acceptance(request: Request, payload: WebChatAcceptanceRequest) -> WebChatAcceptanceResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
acceptance_type=payload.acceptance_type,
comment=payload.comment,
)
except ValueError as exc:
detail = str(exc)
status_code = 404 if "No internal task" in detail else 400
raise HTTPException(status_code=status_code, detail=detail) from exc
return WebChatAcceptanceResponse(**result)
@app.post(
"/api/chat/feedback",
response_model=WebChatFeedbackResponse,
@ -1893,10 +2451,10 @@ def create_app(
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_feedback(
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
feedback_type=payload.feedback_type,
acceptance_type=payload.feedback_type,
comment=payload.comment,
)
except ValueError as exc:
@ -1915,15 +2473,21 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An
role = event.get("role")
if role not in {"user", "assistant"}:
continue
content = event.get("content") or ""
comparable_content = str(content).replace("\u200b", "").replace("\u200c", "").replace("\u200d", "").replace("\ufeff", "")
if role == "assistant" and not comparable_content.strip():
continue
content = _sanitize_user_visible_assistant_content(role=role, content=content)
messages.append(
{
"role": role,
"content": event.get("content") or "",
"content": content,
"timestamp": _iso_from_timestamp(event.get("timestamp")),
"run_id": event.get("run_id"),
"task_id": event.get("task_id"),
"task_status": event.get("task_status"),
"validation_status": event.get("validation_status"),
"evidence_status": event.get("evidence_status"),
"acceptance_state": event.get("acceptance_state"),
"feedback_state": event.get("feedback_state"),
"feedback_error": event.get("feedback_error"),
"message_type": event.get("message_type"),
@ -2142,6 +2706,7 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
content = (record.content or "").strip()
if not content:
continue
content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
messages.append(
{
"role": record.role,
@ -2150,7 +2715,6 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
"tool_name": record.tool_name,
}
)
validation = run_record.validation_result if run_record is not None else None
views.append(
{
"run_id": run_id,
@ -2163,7 +2727,8 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
"attempt_index": run_record.attempt_index if run_record is not None else None,
"task_text": run_record.task_text if run_record is not None else "",
"messages": messages,
"validation_result": validation,
"evidence_status": "recorded",
"validation_result": None,
}
)
return views
@ -2428,12 +2993,6 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
return dict(value)
def _validation_status(validation_result: dict[str, Any] | None) -> str:
if validation_result is None:
return "unknown"
return "passed" if validation_result.get("accepted") is True else "failed"
def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
result: dict[str, Any] = dict(metadata)
@ -2467,13 +3026,15 @@ def _int_or_none(value: Any) -> int | None:
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
validation_result = getattr(result, "validation_result", None)
task_id = getattr(result, "task_id", None)
task_status = getattr(result, "task_status", None)
return {
"type": "message",
"role": "assistant",
"content": getattr(result, "output_text", "") or "",
"content": _sanitize_user_visible_assistant_content(
role="assistant",
content=getattr(result, "output_text", "") or "",
),
"session_id": getattr(result, "session_id", None),
"run_id": getattr(result, "run_id", None),
"finish_reason": getattr(result, "finish_reason", None),
@ -2483,17 +3044,39 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) ->
"usage": dict(getattr(result, "usage", {}) or {}),
"task_id": task_id,
"task_status": task_status,
"validation_result": validation_result,
"validation_status": _validation_status(validation_result),
"evidence_status": "recorded" if task_id else None,
"validation_result": None,
"metadata": {
"task_id": task_id,
"task_status": task_status,
"validation_result": validation_result,
"evidence_status": "recorded" if task_id else None,
"input_metadata": _websocket_input_metadata(input_payload),
},
}
def _sanitize_user_visible_assistant_content(*, role: str, content: str) -> str:
if role != "assistant":
return content
if _looks_like_raw_tool_call(content):
return RAW_TOOL_CALL_DISPLAY_FALLBACK
return content
def _looks_like_raw_tool_call(content: str | None) -> bool:
if not content:
return False
stripped = content.strip()
lowered = stripped.lower()
return (
lowered.startswith("<tool_call")
and lowered.endswith("</tool_call>")
) or (
lowered.startswith("<function=")
and lowered.endswith("</function>")
)
def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
if provider_cfg is None or provider_name == "custom":
return False
@ -2916,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
return f"{secret[:4]}••••{secret[-4:]}"
def _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]:
secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {}
config_payload = data.get("config") if isinstance(data.get("config"), dict) else {}
return {
"channel_id": channel_id,
"enabled": bool(data.get("enabled")),
"kind": _clean_text(data.get("kind")) or "",
"mode": _clean_text(data.get("mode")) or "webhook",
"account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "",
"display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id,
"config": dict(config_payload),
"secrets": {
str(key): _mask_secret(str(value) if value is not None else None)
for key, value in secrets_payload.items()
if str(key).strip()
},
}
def _read_config_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
@ -2980,13 +3582,21 @@ def _write_config_json(path: Path, data: dict[str, Any]) -> None:
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
config = load_config(config_path=config_path)
agent_service.loader.config = config
agent_service._apply_configured_profile_defaults() # noqa: SLF001
loop = getattr(agent_service, "_loop", None)
loaded = getattr(loop, "loaded", None) if loop is not None else None
if loaded is not None:
old_manager = getattr(loaded, "mcp_manager", None)
if old_manager is not None:
async def _close_old_manager() -> None:
await old_manager.close()
try:
await old_manager.close()
except Exception:
# MCP transports may own anyio cancel scopes created by a
# previous request task. Config reload must not leak that
# cleanup failure as an unhandled background exception or
# knock the app out of running mode.
pass
try:
running_loop = asyncio.get_running_loop()

View File

@ -1,11 +1,25 @@
"""Web request and response schemas."""
from .chat import (
WebChatAcceptanceRequest,
WebChatAcceptanceResponse,
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebChannelConfigRequest,
WebChannelConfigResponse,
WebChannelConnectionCreateRequest,
WebChannelConnectionResponse,
WebChannelConnectionUpdateRequest,
WebChannelValidationResponse,
WebConnectorBridgeEventRequest,
WebConnectorBridgeEventResponse,
WebConnectorSessionCreateRequest,
WebConnectorSessionResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebProviderTarget,
@ -13,11 +27,25 @@ from .chat import (
)
__all__ = [
"WebChatAcceptanceRequest",
"WebChatAcceptanceResponse",
"WebChatFeedbackRequest",
"WebChatFeedbackResponse",
"WebChatRequest",
"WebChatResponse",
"WebErrorResponse",
"WebAgentConfigRequest",
"WebAgentConfigResponse",
"WebChannelConfigRequest",
"WebChannelConfigResponse",
"WebChannelConnectionCreateRequest",
"WebChannelConnectionResponse",
"WebChannelConnectionUpdateRequest",
"WebChannelValidationResponse",
"WebConnectorBridgeEventRequest",
"WebConnectorBridgeEventResponse",
"WebConnectorSessionCreateRequest",
"WebConnectorSessionResponse",
"WebProviderConfigRequest",
"WebProviderConfigResponse",
"WebProviderTarget",

View File

@ -82,11 +82,34 @@ class WebChatResponse(BaseModel):
usage: dict[str, Any] = Field(default_factory=dict)
task_id: str | None = None
task_status: str | None = None
evidence_status: str | None = None
acceptance_state: str | None = None
validation_result: dict[str, Any] | None = None
class WebChatAcceptanceRequest(BaseModel):
"""User acceptance on the latest assistant result in chat."""
session_id: str
run_id: str
acceptance_type: str
comment: str | None = None
class WebChatAcceptanceResponse(BaseModel):
"""Acceptance recording result."""
session_id: str
run_id: str
task_id: str
task_status: str
acceptance_type: str
feedback_type: str
learning_candidates: list[dict[str, Any]] = Field(default_factory=list)
class WebChatFeedbackRequest(BaseModel):
"""Feedback on the latest assistant result in chat."""
"""Backward-compatible feedback payload."""
session_id: str
run_id: str
@ -94,15 +117,8 @@ class WebChatFeedbackRequest(BaseModel):
comment: str | None = None
class WebChatFeedbackResponse(BaseModel):
"""Feedback recording result."""
session_id: str
run_id: str
task_id: str
task_status: str
feedback_type: str
learning_candidates: list[dict[str, Any]] = Field(default_factory=list)
class WebChatFeedbackResponse(WebChatAcceptanceResponse):
"""Backward-compatible feedback response."""
class WebProviderConfigRequest(BaseModel):
@ -123,6 +139,127 @@ class WebProviderConfigResponse(BaseModel):
enabled: bool
class WebChannelConfigRequest(BaseModel):
"""Channel config update from the settings page."""
enabled: bool = False
kind: str
mode: str
account_id: str | None = None
display_name: str | None = None
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, str | None] = Field(default_factory=dict)
class WebChannelConfigResponse(BaseModel):
"""Channel config update result."""
ok: bool
channel_id: str
restart_required: bool
channel: dict[str, Any]
class WebChannelConnectionCreateRequest(BaseModel):
"""Create a channel connection from the setup UI."""
kind: str
mode: str
display_name: str | None = Field(default=None, alias="displayName")
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
auth_type: str = Field(default="token", alias="authType")
account_id: str | None = Field(default=None, alias="accountId")
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, str | None] = Field(default_factory=dict)
class WebChannelConnectionResponse(BaseModel):
"""Channel connection response with redacted credentials."""
connection: dict[str, Any]
credentials: dict[str, str] = Field(default_factory=dict)
class WebChannelConnectionUpdateRequest(BaseModel):
"""Update editable channel connection setup fields."""
display_name: str | None = Field(default=None, alias="displayName")
account_id: str | None = Field(default=None, alias="accountId")
config: dict[str, Any] | None = None
secrets: dict[str, str | None] | None = None
class WebChannelValidationResponse(BaseModel):
"""Connector validation response."""
ok: bool
status: str
account_id: str | None = None
display_name: str | None = None
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
connection: dict[str, Any]
class WebConnectorBridgeEventRequest(BaseModel):
"""Inbound connector bridge event from the external sidecar."""
event_id: str = Field(alias="eventId")
timestamp: str
delivery_attempt: int = Field(default=1, alias="deliveryAttempt")
connection_id: str = Field(alias="connectionId")
channel_id: str = Field(alias="channelId")
kind: str
account_id: str = Field(alias="accountId")
peer_id: str = Field(alias="peerId")
peer_type: str = Field(default="unknown", alias="peerType")
user_id: str | None = Field(default=None, alias="userId")
thread_id: str | None = Field(default=None, alias="threadId")
message_id: str = Field(alias="messageId")
message_type: str = Field(default="text", alias="messageType")
content: str
metadata: dict[str, Any] = Field(default_factory=dict)
class WebConnectorBridgeEventResponse(BaseModel):
"""Connector bridge event accept/dedupe response."""
accepted: bool
duplicate: bool = False
pending: bool = False
retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds")
class WebConnectorSessionCreateRequest(BaseModel):
"""Start a connector-managed onboarding session."""
kind: str
display_name: str | None = Field(default=None, alias="displayName")
owner_user_id: str | None = Field(default=None, alias="ownerUserId")
options: dict[str, Any] = Field(default_factory=dict)
class WebConnectorSessionResponse(BaseModel):
"""Connector session view plus optional connection view."""
session: dict[str, Any]
connection: dict[str, Any] | None = None
class WebAgentConfigRequest(BaseModel):
"""Agent runtime defaults update from the settings page."""
max_tokens: int | None = None
temperature: float
max_tool_iterations: int
class WebAgentConfigResponse(BaseModel):
"""Agent runtime defaults update result."""
ok: bool
class WebStatusResponse(BaseModel):
"""Web 宿主层状态响应。"""

View File

@ -29,9 +29,9 @@ from beaver.tasks import (
TaskEvidencePacket,
TaskExecutionPlan,
TaskRecord,
ValidationResult,
render_task_evidence,
)
from beaver.tasks.service import normalize_acceptance_type
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
@ -60,11 +60,27 @@ class AgentService:
) -> None:
self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
self._apply_configured_profile_defaults()
self._loop: AgentLoop | None = None
self._run_task: asyncio.Task[None] | None = None
self._main_agent_router = MainAgentRouter()
self._runtime_services: dict[str, Any] = {}
def _apply_configured_profile_defaults(self) -> None:
defaults = self.loader.config.agents_defaults
self.profile.max_tokens = None
self.profile.temperature = 0.2
self.profile.max_context_messages = 1000
self.profile.max_tool_iterations = 30
if defaults.max_tokens is not None:
self.profile.max_tokens = max(1, defaults.max_tokens)
if defaults.temperature is not None:
self.profile.temperature = defaults.temperature
if defaults.max_context_messages is not None:
self.profile.max_context_messages = max(1, defaults.max_context_messages)
if defaults.max_tool_iterations is not None:
self.profile.max_tool_iterations = max(0, defaults.max_tool_iterations)
def create_loop(self) -> AgentLoop:
"""创建并缓存当前 service 使用的 AgentLoop。"""
@ -232,7 +248,7 @@ class AgentService:
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
This entry bypasses the main-agent classifier and forces Task mode so
every trigger produces a TaskRecord, validation, feedback state, and a
every trigger produces a TaskRecord, evidence, acceptance state, and a
run_id that the scheduled-task history can link to.
"""
@ -280,9 +296,9 @@ class AgentService:
result.run_id,
{
"message_type": "scheduled_reply",
"scheduled_job_id": job.id,
"scheduled_run_id": run.scheduled_run_id,
"cron_job_name": job.name,
"scheduled_job_id": cron_job_id,
"scheduled_run_id": scheduled_run_id,
"cron_job_name": cron_job_name,
"mode": "notification",
},
)
@ -403,15 +419,15 @@ class AgentService:
},
)
async def submit_feedback(
async def submit_acceptance(
self,
*,
session_id: str,
run_id: str,
feedback_type: str,
acceptance_type: str,
comment: str | None = None,
) -> dict[str, Any]:
"""Record chat feedback for the internal task linked to a run."""
"""Record user acceptance for the internal task linked to a run."""
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
@ -419,32 +435,31 @@ class AgentService:
if task is None or task.session_id != session_id:
raise ValueError(f"No internal task found for run_id={run_id!r}")
normalized = feedback_type.strip().lower()
if normalized not in {"satisfied", "revise", "abandon"}:
raise ValueError("feedback_type must be one of: satisfied, revise, abandon")
normalized = normalize_acceptance_type(acceptance_type)
legacy_feedback_type = "satisfied" if normalized == "accept" else normalized
already_recorded = any(
item.get("run_id") == run_id and item.get("feedback_type") == normalized
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
for item in task.feedback
)
conflicting_feedback = next(
conflicting_acceptance = next(
(
item
for item in task.feedback
if item.get("run_id") == run_id and item.get("feedback_type") != normalized
if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
),
None,
)
if conflicting_feedback is not None:
if conflicting_acceptance is not None:
raise ValueError(
f"Feedback for run_id={run_id!r} was already recorded as "
f"{conflicting_feedback.get('feedback_type')!r}"
f"Acceptance for run_id={run_id!r} was already recorded as "
f"{conflicting_acceptance.get('acceptance_type')!r}"
)
if task.status in {"closed", "abandoned"} and not already_recorded:
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
updated = task if already_recorded else task_service.add_feedback(
updated = task if already_recorded else task_service.add_acceptance(
task.task_id,
feedback_type=normalized,
acceptance_type=normalized,
comment=comment,
run_id=run_id,
)
@ -455,7 +470,8 @@ class AgentService:
{
"task_id": updated.task_id,
"task_status": updated.status,
"feedback_state": normalized,
"acceptance_state": normalized,
"feedback_state": legacy_feedback_type,
},
)
if not already_recorded:
@ -463,10 +479,11 @@ class AgentService:
session_id,
run_id=run_id,
role="system",
event_type="task_feedback_recorded",
event_type="task_acceptance_recorded",
event_payload={
"task_id": task.task_id,
"feedback_type": normalized,
"acceptance_type": normalized,
"feedback_type": legacy_feedback_type,
"comment": comment,
"task_status": updated.status,
},
@ -475,35 +492,36 @@ class AgentService:
)
generated_candidates = []
validation = ValidationResult.from_dict(updated.validation_result)
if not already_recorded:
run_memory_store = self._require_loaded(loaded, "run_memory_store")
feedback_payload = {
"feedback_type": normalized,
acceptance_payload = {
"acceptance_type": normalized,
"feedback_type": legacy_feedback_type,
"comment": comment or "",
"task_status": updated.status,
"final_accepted_run_id": updated.metadata.get("final_accepted_run_id"),
}
run_memory_store.update_run_record(
run_id,
success=normalized == "satisfied",
feedback=feedback_payload,
success=normalized == "accept",
feedback=acceptance_payload,
)
run_memory_store.update_skill_effects_for_run(
run_id,
success=normalized == "satisfied",
feedback_score=self._feedback_score_for_learning(normalized, validation),
success=normalized == "accept",
feedback_score=self._acceptance_score_for_learning(normalized),
notes=(comment or normalized).strip(),
)
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
skill_learning_service.rescore_skill_versions()
if already_recorded:
generated_candidates = []
elif normalized == "satisfied" and validation is not None and validation.accepted:
elif normalized == "accept":
generated_candidates = [
item.to_dict()
for item in skill_learning_service.build_learning_candidates_for_task(
updated.task_id,
trigger_run_id=run_id,
final_accepted_run_id=run_id,
)
]
elif normalized == "abandon":
@ -514,7 +532,8 @@ class AgentService:
event_type="task_failure_evidence_recorded",
event_payload={
"task_id": updated.task_id,
"feedback_type": normalized,
"acceptance_type": normalized,
"feedback_type": legacy_feedback_type,
"comment": comment or "",
"task_status": updated.status,
"durable_memory_written": False,
@ -528,10 +547,28 @@ class AgentService:
"run_id": run_id,
"task_id": updated.task_id,
"task_status": updated.status,
"feedback_type": normalized,
"acceptance_type": normalized,
"feedback_type": legacy_feedback_type,
"learning_candidates": generated_candidates,
}
async def submit_feedback(
self,
*,
session_id: str,
run_id: str,
feedback_type: str,
comment: str | None = None,
) -> dict[str, Any]:
"""Backward-compatible wrapper for older clients."""
return await self.submit_acceptance(
session_id=session_id,
run_id=run_id,
acceptance_type=feedback_type,
comment=comment,
)
async def _process_with_main_agent(
self,
message: str,
@ -591,7 +628,7 @@ class AgentService:
else active_task
)
if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
task = self._record_revision_feedback_for_task(
task = self._record_revision_acceptance_for_task(
loaded,
task=task,
session_id=session_id,
@ -599,7 +636,7 @@ class AgentService:
)
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
def _record_revision_feedback_for_task(
def _record_revision_acceptance_for_task(
self,
loaded: Any,
*,
@ -607,9 +644,9 @@ class AgentService:
session_id: str,
comment: str,
) -> TaskRecord:
"""Mark the latest feedback-eligible run as revised before continuing a task."""
"""Mark the latest acceptance-eligible run as revised before continuing a task."""
if task.status not in {"awaiting_feedback", "needs_revision"}:
if task.status not in {"awaiting_acceptance", "needs_revision"}:
return task
run_id = next((item for item in reversed(task.run_ids) if item), None)
if not run_id:
@ -617,15 +654,15 @@ class AgentService:
existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
if existing is not None:
if existing.get("feedback_type") != "revise":
if existing.get("acceptance_type") != "revise":
return task
updated = task
already_recorded = True
else:
task_service = self._require_loaded(loaded, "task_service")
updated = task_service.add_feedback(
updated = task_service.add_acceptance(
task.task_id,
feedback_type="revise",
acceptance_type="revise",
comment=comment,
run_id=run_id,
)
@ -638,6 +675,7 @@ class AgentService:
{
"task_id": updated.task_id,
"task_status": updated.status,
"acceptance_state": "revise",
"feedback_state": "revise",
},
)
@ -648,9 +686,10 @@ class AgentService:
session_id,
run_id=run_id,
role="system",
event_type="task_feedback_recorded",
event_type="task_acceptance_recorded",
event_payload={
"task_id": updated.task_id,
"acceptance_type": "revise",
"feedback_type": "revise",
"comment": comment,
"task_status": updated.status,
@ -659,12 +698,12 @@ class AgentService:
content=comment,
context_visible=False,
)
validation = ValidationResult.from_dict(updated.validation_result)
run_memory_store = self._require_loaded(loaded, "run_memory_store")
run_memory_store.update_run_record(
run_id,
success=False,
feedback={
"acceptance_type": "revise",
"feedback_type": "revise",
"comment": comment,
"task_status": updated.status,
@ -673,7 +712,7 @@ class AgentService:
run_memory_store.update_skill_effects_for_run(
run_id,
success=False,
feedback_score=self._feedback_score_for_learning("revise", validation),
feedback_score=self._acceptance_score_for_learning("revise"),
notes=comment.strip() or "revise",
)
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
@ -690,236 +729,185 @@ class AgentService:
) -> AgentRunResult:
loaded = self.create_loop().boot()
task_service = self._require_loaded(loaded, "task_service")
validation_service = self._require_loaded(loaded, "validation_service")
task_execution_planner = self._require_loaded(loaded, "task_execution_planner")
session_manager = self._require_loaded(loaded, "session_manager")
run_memory_store = self._require_loaded(loaded, "run_memory_store")
last_result: AgentRunResult | None = None
latest_validation: ValidationResult | None = None
base_execution_context = kwargs.get("execution_context")
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
kwargs = dict(kwargs)
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
kwargs["provider_bundle"] = provider_bundle
for attempt_index in (1, 2):
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
plan = await task_execution_planner.plan(
attempt_index = int(task.metadata.get("latest_attempt_index") or 0) + 1
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
plan = await task_execution_planner.plan(
task=task,
user_message=message,
attempt_index=attempt_index,
provider_bundle=provider_bundle,
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_execution_planned",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
**plan.to_event_payload(),
},
)
team_summaries: list[str] = []
team_execution_context = ""
team_result: TeamRunResult | None = None
if plan.is_team:
team_result, team_error = await self._run_team_for_task(
plan,
task=task,
user_message=message,
attempt_index=attempt_index,
latest_validation=latest_validation,
provider_bundle=provider_bundle,
parent_session_id=kwargs["session_id"],
provider_bundle_factory=team_provider_bundle_factory
or self._build_team_provider_bundle_factory(loaded, kwargs),
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_execution_planned",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
**plan.to_event_payload(),
},
)
team_summaries: list[str] = []
team_execution_context = ""
team_result: TeamRunResult | None = None
if plan.is_team:
team_result, team_error = await self._run_team_for_task(
plan,
task=task,
parent_session_id=kwargs["session_id"],
provider_bundle_factory=team_provider_bundle_factory
or self._build_team_provider_bundle_factory(loaded, kwargs),
if team_result is not None:
team_summaries = [self._team_summary_for_validation(team_result)]
team_packet = TaskEvidencePacket(
task_id=task.task_id,
attempt_index=attempt_index,
main_run=None,
team_runs=self._team_run_evidence(team_result),
team_node_results=list(team_result.node_results),
final_output="",
)
team_execution_context = self._join_context(
self._team_execution_context(plan, team_result),
"Rendered team evidence:\n" + render_task_evidence(team_packet),
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": team_result.run_ids,
"team_success": team_result.success,
"node_results": self._team_node_results_for_event(plan, team_result),
"reason": plan.reason,
"error": None if team_result.success else "one or more team nodes failed",
},
)
else:
team_summaries = [f"Team execution failed: {team_error}"]
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": [],
"team_success": False,
"reason": plan.reason,
"error": team_error,
},
)
if team_result is not None:
team_summaries = [self._team_summary_for_validation(team_result)]
team_packet = TaskEvidencePacket(
task_id=task.task_id,
attempt_index=attempt_index,
main_run=None,
team_runs=self._team_run_evidence(team_result),
team_node_results=list(team_result.node_results),
final_output="",
)
team_execution_context = self._join_context(
self._team_execution_context(plan, team_result),
"Rendered team evidence:\n" + render_task_evidence(team_packet),
)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_completed" if team_result.success else "task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": team_result.run_ids,
"team_success": team_result.success,
"node_results": self._team_node_results_for_event(plan, team_result),
"reason": plan.reason,
"error": None if team_result.success else "one or more team nodes failed",
},
)
else:
team_summaries = [f"Team execution failed: {team_error}"]
team_execution_context = self._failed_team_execution_context(plan, team_error or "unknown error")
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_team_run_failed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
"node_ids": [node.node_id for node in plan.graph.nodes] if plan.graph else [],
"team_run_ids": [],
"team_success": False,
"reason": plan.reason,
"error": team_error,
},
)
attempt_kwargs = dict(kwargs)
attempt_kwargs.update(
{
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"allow_candidate_generation": False,
}
)
if attempt_index == 2 and latest_validation is not None:
revision_context = latest_validation.recommended_revision_prompt.strip()
if revision_context:
attempt_kwargs["execution_context"] = self._join_context(
base_execution_context,
f"Task validation revision request:\n{revision_context}",
team_execution_context,
)
elif team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
if plan.is_team and team_execution_context:
attempt_kwargs["include_tools"] = False
attempt_kwargs["max_tool_iterations"] = 0
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
latest_validation=latest_validation,
plan=plan,
team_summaries=team_summaries,
)
result = await runner(message, **attempt_kwargs)
last_result = result
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_synthesis_completed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"main_run_id": result.run_id,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
},
)
task = task_service.append_run(
task.task_id,
result.run_id,
skill_names=self._skill_names_for_run(loaded, result.run_id),
)
evidence_packet = self._build_task_evidence_packet(
session_manager=session_manager,
task=task,
attempt_index=attempt_index,
result=result,
team_result=team_result,
)
evidence_text = render_task_evidence(evidence_packet)
validation = await validation_service.validate_task_result(
task=task,
user_message=message,
final_output=result.output_text,
evidence_packet=evidence_packet,
evidence_text=evidence_text,
transcript_excerpt=self._run_excerpt(session_manager, result.session_id, result.run_id),
tool_summaries=self._tool_summaries(session_manager, result.session_id, result.run_id),
team_summaries=team_summaries,
provider_bundle=provider_bundle,
)
latest_validation = validation
has_usable_answer = bool(result.output_text.strip()) and (
"Tool loop stopped after reaching the configured iteration limit." not in result.output_text
)
task = task_service.record_validation(
task.task_id,
result.run_id,
validation,
final_attempt=(
attempt_index == 2
or validation.status in {"accepted", "insufficient_evidence", "validator_error"}
),
has_usable_answer=has_usable_answer,
)
run_memory_store.update_run_record(result.run_id, validation_result=validation.to_dict())
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"task_id": task.task_id,
"task_status": task.status,
"validation_status": "passed" if validation.accepted else "failed",
},
)
validation_debug = {
"evidence_run_ids": [
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
],
"evidence_session_ids": [
item.session_id
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
],
"tool_result_count": sum(
len(item.tool_results)
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
),
"evidence_length": len(evidence_text),
attempt_kwargs = dict(kwargs)
attempt_kwargs.update(
{
"task_id": task.task_id,
"task_mode": True,
"attempt_index": attempt_index,
"allow_candidate_generation": False,
}
retry_scheduled = validation.status == "rejected" and attempt_index == 1
session_manager.append_message(
result.session_id,
run_id=result.run_id,
role="system",
event_type="task_validation_snapshotted",
event_payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"validation_result": validation.to_dict(),
"validation_debug": validation_debug,
"retry_scheduled": retry_scheduled,
},
content=validation.recommended_revision_prompt or None,
context_visible=False,
)
if retry_scheduled:
session_manager.set_run_context_visible(result.session_id, result.run_id, False)
result.task_id = task.task_id
result.task_status = task.status
result.validation_result = validation.to_dict()
if not retry_scheduled:
return result
)
if team_execution_context:
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
if plan.is_team and team_execution_context:
attempt_kwargs["include_tools"] = False
attempt_kwargs["max_tool_iterations"] = 0
attempt_kwargs["skill_selection_context"] = self._build_skill_selection_context(
task=task,
user_message=message,
attempt_index=attempt_index,
plan=plan,
team_summaries=team_summaries,
)
if last_result is None: # pragma: no cover - defensive
raise RuntimeError("Task mode did not produce a run result")
return last_result
result = await runner(message, **attempt_kwargs)
self._append_task_observation(
session_manager,
task.session_id,
event_type="task_synthesis_completed",
payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"main_run_id": result.run_id,
"plan_mode": plan.mode,
"strategy": plan.graph.strategy if plan.graph else None,
},
)
task = task_service.append_run(
task.task_id,
result.run_id,
skill_names=self._skill_names_for_run(loaded, result.run_id),
)
evidence_packet = self._build_task_evidence_packet(
session_manager=session_manager,
task=task,
attempt_index=attempt_index,
result=result,
team_result=team_result,
)
evidence_text = render_task_evidence(evidence_packet)
evidence_debug = {
"evidence_run_ids": [
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
],
"evidence_session_ids": [
item.session_id
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
],
"tool_result_count": sum(
len(item.tool_results)
for item in [evidence_packet.main_run, *evidence_packet.team_runs]
if item is not None
),
"evidence_length": len(evidence_text),
}
session_manager.update_latest_assistant_event_payload(
result.session_id,
result.run_id,
{
"task_id": task.task_id,
"task_status": task.status,
"evidence_status": "recorded",
},
)
session_manager.append_message(
result.session_id,
run_id=result.run_id,
role="system",
event_type="task_evidence_recorded",
event_payload={
"task_id": task.task_id,
"attempt_index": attempt_index,
"evidence_debug": evidence_debug,
},
content=None,
context_visible=False,
)
result.task_id = task.task_id
result.task_status = task.status
result.validation_result = None
return result
async def _run_team_for_task(
self,
@ -986,12 +974,10 @@ class AgentService:
return []
@staticmethod
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
if feedback_type == "satisfied":
if validation is not None:
return max(0.0, min(1.0, float(validation.score)))
def _acceptance_score_for_learning(acceptance_type: str) -> float:
if acceptance_type == "accept":
return 1.0
if feedback_type == "revise":
if acceptance_type == "revise":
return 0.5
return 0.0
@ -1001,12 +987,11 @@ class AgentService:
task: TaskRecord,
user_message: str,
attempt_index: int,
latest_validation: ValidationResult | None = None,
plan: TaskExecutionPlan | None = None,
team_summaries: list[str] | None = None,
) -> str:
phase = f"attempt_{attempt_index}"
if latest_validation is not None:
if task.feedback and task.feedback[-1].get("acceptance_type") == "revise":
phase = f"revision_attempt_{attempt_index}"
elif plan is not None and plan.is_team:
phase = f"team_synthesis_attempt_{attempt_index}"
@ -1027,24 +1012,14 @@ class AgentService:
)
else:
sections.append("Previously activated skills:\nNone")
if latest_validation is not None:
validation_lines = [
f"accepted: {latest_validation.accepted}",
f"score: {latest_validation.score}",
]
if latest_validation.issues:
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
if latest_validation.missing_requirements:
validation_lines.append(
"missing requirements:\n"
+ "\n".join(f"- {item}" for item in latest_validation.missing_requirements)
)
if latest_validation.recommended_revision_prompt:
validation_lines.append(
"recommended revision:\n"
+ latest_validation.recommended_revision_prompt
)
sections.append("Validation feedback:\n" + "\n".join(validation_lines))
if task.feedback:
history_lines = []
for item in task.feedback[-5:]:
kind = item.get("acceptance_type") or item.get("feedback_type")
comment = item.get("comment") or ""
run_id = item.get("run_id") or ""
history_lines.append(f"- {kind} run={run_id}: {comment}".strip())
sections.append("Task acceptance history:\n" + "\n".join(history_lines))
if plan is not None:
plan_lines = [
f"mode: {plan.mode}",
@ -1262,17 +1237,19 @@ class AgentService:
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
channel_identity = inbound.channel_identity
try:
result = await self.submit_direct(
inbound.content,
session_id=inbound.session_id,
source=f"gateway:{inbound.channel}",
user_id=inbound.user_id,
user_id=inbound.user_id or (channel_identity.user_id if channel_identity else None),
title=inbound.title,
execution_context=inbound.execution_context,
model=inbound.model,
provider_name=inbound.provider_name,
embedding_model=inbound.embedding_model,
channel_identity=channel_identity,
)
except Exception as exc:
return self.build_outbound_error(
@ -1308,12 +1285,15 @@ class AgentService:
finish_reason=result.finish_reason,
provider_name=result.provider_name,
model=result.model,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
usage=dict(result.usage),
metadata={
"inbound_metadata": dict(inbound.metadata),
"task_id": getattr(result, "task_id", None),
"task_status": getattr(result, "task_status", None),
"validation_result": getattr(result, "validation_result", None),
"evidence_status": "recorded" if getattr(result, "task_id", None) else None,
"validation_result": None,
},
)
@ -1332,6 +1312,8 @@ class AgentService:
session_id=inbound.session_id,
content=detail,
finish_reason=finish_reason,
content_type=inbound.content_type,
channel_identity=inbound.channel_identity,
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
)

View File

@ -50,10 +50,11 @@ class SessionProcessProjector:
for record in records:
payload = dict(record.event_payload or {})
task_id = payload.get("task_id")
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
if not task_id:
continue
attempt_index = int(payload.get("attempt_index") or 1)
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
created_at = _timestamp(record.timestamp)
root = runs.setdefault(
@ -73,15 +74,70 @@ class SessionProcessProjector:
},
)
if record.event_type == "task_execution_planned":
if record.event_type == "assistant_message_added" and record.tool_calls:
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
for index, tool_call in enumerate(record.tool_calls):
if not isinstance(tool_call, dict):
continue
tool_name = _tool_call_name(tool_call)
add_event(
event_id=f"{_event_id(record, 'tool-call')}:{index}",
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_started",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=f"Calling tool: {tool_name}.",
created_at=created_at,
status="running",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_call",
"tool_name": tool_name,
"tool_call_id": tool_call.get("id"),
"arguments": _tool_call_arguments(tool_call),
},
)
elif record.event_type == "tool_result_recorded":
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
add_event(
event_id=_event_id(record, "tool-result"),
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_finished",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=_truncate(str(record.content or payload.get("error") or "")),
created_at=created_at,
status="done" if payload.get("success", True) else "error",
metadata={
**dict(payload),
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_result",
"tool_name": tool_name,
"tool_call_id": record.tool_call_id,
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
},
)
elif record.event_type == "task_execution_planned":
plan_mode = payload.get("plan_mode") or "single"
strategy = payload.get("strategy") or "single"
node_ids = payload.get("node_ids") or []
root["title"] = f"{payload.get('plan_mode', 'single')} plan: {strategy}"
root["title"] = f"{plan_mode} plan: {strategy}"
root["summary"] = payload.get("reason") or ""
root["metadata"] = {
**root.get("metadata", {}),
"plan_mode": payload.get("plan_mode"),
"strategy": payload.get("strategy"),
"plan_mode": plan_mode,
"strategy": strategy,
"node_ids": node_ids,
"skill_queries": payload.get("skill_queries") or [],
"selected_skill_names": payload.get("selected_skill_names") or [],
@ -92,36 +148,65 @@ class SessionProcessProjector:
add_event(
event_id=_event_id(record, "planned"),
run_id=root_run_id,
kind="run_started",
kind="task_planned",
actor_type="system",
actor_id="task",
actor_name="Task Planner",
text=f"Planned {payload.get('plan_mode')} execution via {strategy}. {payload.get('reason') or ''}".strip(),
text=f"Beaver planned {plan_mode} execution via {strategy}. {payload.get('reason') or ''}".strip(),
created_at=created_at,
status="running",
metadata=root["metadata"],
metadata={
**root["metadata"],
"timeline_type": "plan",
"user_summary": f"Beaver will use {plan_mode} execution for this task.",
},
)
selected_skill_names = [
str(item)
for item in payload.get("selected_skill_names") or []
if str(item).strip()
]
if selected_skill_names:
add_event(
event_id=_event_id(record, "skills"),
run_id=root_run_id,
kind="skill_selected",
actor_type="system",
actor_id="skill-selector",
actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(selected_skill_names)}.",
created_at=created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": selected_skill_names,
"reason": payload.get("reason") or "Selected from task planning context.",
},
)
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
team_success = bool(payload.get("team_success"))
root["status"] = "running"
team_run_ids = payload.get("team_run_ids") or []
root["metadata"] = {
**root.get("metadata", {}),
"team_success": team_success,
"team_run_ids": payload.get("team_run_ids") or [],
"team_run_ids": team_run_ids,
"team_error": payload.get("error"),
}
add_event(
event_id=_event_id(record, "team"),
run_id=root_run_id,
kind="run_status",
kind="agent_team_created",
actor_type="system",
actor_id="team",
actor_name="Task Team",
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
created_at=created_at,
status="done" if team_success else "error",
metadata=dict(payload),
metadata={**dict(payload), "timeline_type": "agent_team", "team_run_ids": team_run_ids},
)
node_results = payload.get("node_results") or []
for item in node_results:
@ -192,20 +277,26 @@ class SessionProcessProjector:
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
run_id=str(node_run_id),
parent_run_id=root_run_id,
kind="run_finished",
kind="agent_finished",
actor_type="agent",
actor_id=str(item.get("node_id") or "sub-agent"),
actor_name=str(item.get("node_id") or "Sub-agent"),
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
created_at=created_at,
status=status,
metadata=dict(item),
metadata={
**dict(item),
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "agent_progress",
},
)
elif record.event_type == "task_synthesis_completed":
main_run_id = str(payload.get("main_run_id") or "")
if main_run_id:
run_record = run_records.get(main_run_id)
activated_skill_names = _activated_skill_names(run_record)
runs[main_run_id] = {
"run_id": main_run_id,
"parent_run_id": root_run_id,
@ -219,8 +310,32 @@ class SessionProcessProjector:
"started_at": run_record.started_at if run_record is not None else created_at,
"finished_at": run_record.ended_at if run_record is not None else created_at,
"summary": _truncate(run_record.task_text if run_record is not None else ""),
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"skill_names": activated_skill_names,
},
}
if activated_skill_names:
add_event(
event_id=_event_id(record, "synthesis-skills"),
run_id=main_run_id,
parent_run_id=root_run_id,
kind="skill_selected",
actor_type="system",
actor_id="skill-selector",
actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
created_at=created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": activated_skill_names,
"activation_reasons": _activated_skill_reasons(run_record),
},
)
add_event(
event_id=_event_id(record, "synthesis"),
run_id=main_run_id,
@ -235,27 +350,46 @@ class SessionProcessProjector:
metadata=dict(payload),
)
elif record.event_type == "task_validation_snapshotted":
validation = payload.get("validation_result") if isinstance(payload.get("validation_result"), dict) else {}
accepted = bool(validation.get("accepted"))
root["status"] = "done" if accepted or attempt_index == 2 else "waiting"
root["finished_at"] = created_at if root["status"] == "done" else None
elif record.event_type == "task_evidence_recorded":
root["status"] = "waiting"
root["finished_at"] = None
add_event(
event_id=_event_id(record, "validation"),
event_id=_event_id(record, "evidence"),
run_id=record.run_id or root_run_id,
parent_run_id=root_run_id if record.run_id else None,
kind="run_status",
kind="task_result_ready",
actor_type="system",
actor_id="validator",
actor_name="Validator",
text=(
f"Validation {'passed' if accepted else 'failed'} "
f"(score={validation.get('score')})."
+ (" Retry scheduled." if payload.get("retry_scheduled") else "")
),
actor_id="evidence-recorder",
actor_name="Evidence",
text="The task result is ready for user acceptance.",
created_at=created_at,
status="done" if accepted else "error",
metadata=dict(payload),
status="done",
metadata={**dict(payload), "timeline_type": "result"},
)
elif record.event_type == "task_acceptance_recorded":
acceptance_type = str(payload.get("acceptance_type") or payload.get("feedback_type") or "")
if acceptance_type == "accept":
root["status"] = "done"
root["finished_at"] = created_at
elif acceptance_type == "abandon":
root["status"] = "cancelled"
root["finished_at"] = created_at
else:
root["status"] = "waiting"
root["finished_at"] = None
add_event(
event_id=_event_id(record, "acceptance"),
run_id=record.run_id or root_run_id,
parent_run_id=root_run_id if record.run_id else None,
kind="task_acceptance_recorded",
actor_type="user",
actor_id="user-acceptance",
actor_name="User Acceptance",
text=f"User acceptance recorded: {acceptance_type or 'unknown'}.",
created_at=created_at,
status="done",
metadata={**dict(payload), "timeline_type": "acceptance"},
)
return {
@ -281,3 +415,49 @@ def _truncate(text: str, limit: int = 800) -> str:
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1] + "..."
def _activated_skill_names(run_record: Any | None) -> list[str]:
if run_record is None:
return []
names = []
for receipt in getattr(run_record, "activated_skills", []) or []:
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
if skill_name:
names.append(skill_name)
return list(dict.fromkeys(names))
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
if run_record is None:
return []
reasons = []
for receipt in getattr(run_record, "activated_skills", []) or []:
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
if reason:
reasons.append(reason)
return reasons
def _tool_call_name(tool_call: dict[str, Any]) -> str:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict):
name = function_payload.get("name")
if name:
return str(name)
for key in ("name", "tool_name"):
value = tool_call.get(key)
if value:
return str(value)
return "tool"
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict) and "arguments" in function_payload:
return function_payload.get("arguments")
if "arguments" in tool_call:
return tool_call.get("arguments")
if "args" in tool_call:
return tool_call.get("args")
return None

View File

@ -69,15 +69,24 @@ class SkillLearningService:
existing_ids.add(candidate.candidate_id)
return candidates
def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]:
"""Build candidates scoped to a single validated and satisfied Task run."""
def build_learning_candidates_for_task(
self,
task_id: str,
*,
final_accepted_run_id: str | None = None,
trigger_run_id: str | None = None,
) -> list[SkillLearningCandidate]:
"""Build candidates from a user-accepted Task and all of its runs."""
final_accepted_run_id = final_accepted_run_id or trigger_run_id
if not final_accepted_run_id:
return []
runs = [record for record in self.run_store.list_runs() if record.task_id == task_id]
trigger_run = next((record for record in runs if record.run_id == trigger_run_id), None)
if trigger_run is None or not self._is_confirmed_positive_run(trigger_run):
final_run = next((record for record in runs if record.run_id == final_accepted_run_id), None)
if final_run is None or not self._is_task_accepted_run(final_run):
return []
source_runs = [record for record in runs if self._is_confirmed_positive_run(record)]
source_runs = sorted(runs, key=lambda item: (item.started_at, item.run_id))
if not source_runs:
return []
@ -100,11 +109,16 @@ class SkillLearningService:
source_session_ids=source_session_ids,
related_skill_names=[],
reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.",
evidence={"task_id": task_id, "trigger_run_id": trigger_run_id, "theme": self._task_theme(trigger_run.task_text)},
evidence={
"task_id": task_id,
"final_accepted_run_id": final_accepted_run_id,
"source_run_ids": source_run_ids,
"theme": self._task_theme(final_run.task_text),
},
status="open",
priority=1,
confidence=0.8,
trigger_reason="validation_accepted_and_user_satisfied",
trigger_reason="task_accepted",
)
)
else:
@ -137,13 +151,14 @@ class SkillLearningService:
),
evidence={
"task_id": task_id,
"trigger_run_id": trigger_run_id,
"final_accepted_run_id": final_accepted_run_id,
"source_run_ids": source_run_ids,
"skill_version": receipt.skill_version,
},
status="open",
priority=1,
confidence=0.7,
trigger_reason="validation_accepted_and_user_satisfied",
trigger_reason="task_accepted",
)
)
@ -269,7 +284,7 @@ class SkillLearningService:
groups.setdefault(key, []).append(record)
candidates: list[SkillLearningCandidate] = []
for theme, runs in groups.items():
successful = [record for record in runs if self._is_confirmed_positive_run(record)]
successful = [record for record in runs if self._is_task_accepted_run(record)]
if len(successful) < 2:
continue
if any(record.activated_skills for record in successful):
@ -290,7 +305,7 @@ class SkillLearningService:
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
for record in self.run_store.list_runs():
if not self._is_confirmed_positive_run(record):
if not self._is_task_accepted_run(record):
continue
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
for pair in combinations(unique, 2):
@ -351,14 +366,15 @@ class SkillLearningService:
return effects
@staticmethod
def _is_confirmed_positive_run(record: RunRecord) -> bool:
validation = record.validation_result or {}
def _is_task_accepted_run(record: RunRecord) -> bool:
feedback = record.feedback or {}
acceptance_type = feedback.get("acceptance_type")
if acceptance_type is None and feedback.get("feedback_type") == "satisfied":
acceptance_type = "accept"
return (
bool(record.success)
and bool(record.task_id)
and validation.get("accepted") is True
and feedback.get("feedback_type") == "satisfied"
and acceptance_type == "accept"
)
@staticmethod

View File

@ -6,7 +6,6 @@ from .planner import TaskExecutionPlan, TaskExecutionPlanner
from .router import MainAgentRouter
from .service import TaskService
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
from .validation import ValidationService
__all__ = [
"EvidenceBuilder",
@ -24,6 +23,5 @@ __all__ = [
"ToolEvidence",
"ValidationResult",
"ValidationStatus",
"ValidationService",
"render_task_evidence",
]

View File

@ -1,4 +1,4 @@
"""Models for internal task tracking and validation."""
"""Models for internal task tracking and user acceptance."""
from __future__ import annotations
@ -9,7 +9,12 @@ from typing import Any, Literal
ValidationStatus = Literal["accepted", "rejected", "insufficient_evidence", "validator_error"]
VALIDATION_STATUSES = {"accepted", "rejected", "insufficient_evidence", "validator_error"}
TASK_OPEN_STATUSES = {"open", "running", "validating", "awaiting_feedback", "needs_review", "needs_revision"}
TASK_OPEN_STATUSES = {"open", "running", "awaiting_acceptance", "needs_revision"}
LEGACY_STATUS_MAP = {
"validating": "running",
"awaiting_feedback": "awaiting_acceptance",
"needs_review": "awaiting_acceptance",
}
@dataclass(slots=True)
@ -113,11 +118,11 @@ class TaskRecord:
@property
def is_execution_active(self) -> bool:
return self.status in {"running", "validating"}
return self.status == "running"
@property
def requires_user_action(self) -> bool:
return self.status in {"awaiting_feedback", "needs_review", "needs_revision"}
return self.status in {"awaiting_acceptance", "needs_revision"}
def to_dict(self) -> dict[str, Any]:
return {
@ -137,6 +142,7 @@ class TaskRecord:
"satisfaction": self.satisfaction,
"run_ids": list(self.run_ids),
"skill_names": list(self.skill_names),
"acceptance": list(self.feedback),
"feedback": list(self.feedback),
"validation_result": self.validation_result,
"metadata": dict(self.metadata),
@ -152,7 +158,7 @@ class TaskRecord:
goal=str(payload.get("goal") or payload.get("description") or ""),
constraints=[str(item) for item in payload.get("constraints") or []],
priority=int(payload.get("priority", 0) or 0),
status=str(payload.get("status") or "open"),
status=LEGACY_STATUS_MAP.get(str(payload.get("status") or "open"), str(payload.get("status") or "open")),
creator=str(payload.get("creator") or "main-agent"),
created_at=str(payload.get("created_at") or ""),
updated_at=str(payload.get("updated_at") or ""),
@ -161,7 +167,11 @@ class TaskRecord:
satisfaction=_optional_float(payload.get("satisfaction")),
run_ids=[str(item) for item in payload.get("run_ids") or []],
skill_names=[str(item) for item in payload.get("skill_names") or []],
feedback=[dict(item) for item in payload.get("feedback") or [] if isinstance(item, dict)],
feedback=[
_normalize_acceptance_entry(dict(item))
for item in (payload.get("acceptance") or payload.get("feedback") or [])
if isinstance(item, dict)
],
validation_result=dict(payload["validation_result"]) if isinstance(payload.get("validation_result"), dict) else None,
metadata=dict(payload.get("metadata") or {}),
)
@ -226,3 +236,13 @@ def _optional_float(value: Any) -> float | None:
if value in (None, ""):
return None
return float(value)
def _normalize_acceptance_entry(entry: dict[str, Any]) -> dict[str, Any]:
if entry.get("acceptance_type") is None and entry.get("feedback_type") is not None:
feedback_type = str(entry.get("feedback_type") or "")
entry["acceptance_type"] = "accept" if feedback_type == "satisfied" else feedback_type
if entry.get("feedback_type") is None and entry.get("acceptance_type") is not None:
acceptance_type = str(entry.get("acceptance_type") or "")
entry["feedback_type"] = "satisfied" if acceptance_type == "accept" else acceptance_type
return entry

View File

@ -10,7 +10,7 @@ from typing import Any, Literal
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.engine.providers import ProviderBundle
from .models import TaskRecord, ValidationResult
from .models import TaskRecord
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
@ -76,7 +76,6 @@ class TaskExecutionPlanner:
task: TaskRecord,
user_message: str,
attempt_index: int,
latest_validation: ValidationResult | None = None,
provider_bundle: ProviderBundle | None = None,
timeout_seconds: float = 30.0,
) -> TaskExecutionPlan:
@ -105,7 +104,6 @@ class TaskExecutionPlanner:
task=task,
user_message=user_message,
attempt_index=attempt_index,
latest_validation=latest_validation,
),
},
],
@ -230,14 +228,10 @@ class TaskExecutionPlanner:
task: TaskRecord,
user_message: str,
attempt_index: int,
latest_validation: ValidationResult | None,
) -> str:
validation_note = ""
if latest_validation is not None:
validation_note = (
"\nPrevious validation issues:\n"
+ json.dumps(latest_validation.to_dict(), ensure_ascii=False)
)
history_note = ""
if task.feedback:
history_note = "\nRelevant task history:\n" + json.dumps(task.feedback[-5:], ensure_ascii=False)
return (
"Decide execution mode for this internal Task attempt.\n"
"Use mode=team only when independent research, review, implementation slices, or staged checks "
@ -254,7 +248,7 @@ class TaskExecutionPlanner:
f"Task goal:\n{task.goal}\n\n"
f"Current user request:\n{user_message}\n\n"
f"Attempt index: {attempt_index}\n"
f"{validation_note}"
f"{history_note}"
)
@staticmethod

View File

@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from .models import TaskEvent, TaskRecord, ValidationResult
from .models import TaskEvent, TaskRecord
from .store import TaskStore
@ -105,38 +105,70 @@ class TaskService:
for name in skill_names or []:
if name not in task.skill_names:
task.skill_names.append(name)
task.status = "awaiting_acceptance"
task.updated_at = self._now()
self.store.upsert_task(task)
self._event(task, "run_completed", run_id=run_id, payload={"skill_names": skill_names or []})
self._event(task, "evidence_recorded", run_id=run_id, payload={"skill_names": skill_names or []})
return task
def record_validation(
def add_acceptance(
self,
task_id: str,
run_id: str,
validation: ValidationResult,
*,
final_attempt: bool = True,
has_usable_answer: bool = True,
acceptance_type: str,
comment: str | None = None,
run_id: str | None = None,
) -> TaskRecord:
task = self._require(task_id)
now = self._now()
if validation.status == "accepted":
task.status = "awaiting_feedback"
elif validation.status in {"insufficient_evidence", "validator_error"}:
task.status = "needs_review"
elif validation.status == "rejected" and not final_attempt:
normalized = normalize_acceptance_type(acceptance_type)
matching_acceptance = any(
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
for item in task.feedback
)
conflicting_acceptance = next(
(
item
for item in task.feedback
if item.get("run_id") == run_id and item.get("acceptance_type") != normalized
),
None,
)
if conflicting_acceptance is not None:
raise ValueError(
f"Acceptance for run_id={run_id!r} was already recorded as "
f"{conflicting_acceptance.get('acceptance_type')!r}"
)
if task.status in {"closed", "abandoned"} and not matching_acceptance:
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
if matching_acceptance:
return task
entry = {
"acceptance_type": normalized,
"feedback_type": "satisfied" if normalized == "accept" else normalized,
"comment": comment or "",
"run_id": run_id,
"created_at": now,
}
task.feedback.append(entry)
if normalized == "revise":
task.status = "needs_revision"
elif validation.status == "rejected" and has_usable_answer:
task.status = "needs_review"
else:
task.status = "failed"
elif normalized == "abandon":
task.status = "abandoned"
task.closed_at = now
task.close_reason = "automatic validation rejected the final attempt"
task.close_reason = comment or "abandoned"
elif normalized == "accept":
task.status = "closed"
task.closed_at = now
task.close_reason = "accepted"
task.satisfaction = 1.0
if run_id:
task.metadata["final_accepted_run_id"] = run_id
task.updated_at = now
task.validation_result = validation.to_dict()
self.store.upsert_task(task)
self._event(task, "validated", run_id=run_id, payload=validation.to_dict())
self._event(task, f"acceptance_{normalized}", run_id=run_id, payload=entry)
return task
def add_feedback(
@ -147,52 +179,12 @@ class TaskService:
comment: str | None = None,
run_id: str | None = None,
) -> TaskRecord:
task = self._require(task_id)
now = self._now()
matching_feedback = any(
item.get("run_id") == run_id and item.get("feedback_type") == feedback_type
for item in task.feedback
return self.add_acceptance(
task_id,
acceptance_type=feedback_type,
comment=comment,
run_id=run_id,
)
conflicting_feedback = next(
(
item
for item in task.feedback
if item.get("run_id") == run_id and item.get("feedback_type") != feedback_type
),
None,
)
if conflicting_feedback is not None:
raise ValueError(
f"Feedback for run_id={run_id!r} was already recorded as "
f"{conflicting_feedback.get('feedback_type')!r}"
)
if task.status in {"closed", "abandoned"} and not matching_feedback:
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
if matching_feedback:
return task
entry = {
"feedback_type": feedback_type,
"comment": comment or "",
"run_id": run_id,
"created_at": now,
}
task.feedback.append(entry)
if feedback_type == "revise":
task.status = "needs_revision"
elif feedback_type == "abandon":
task.status = "abandoned"
task.closed_at = now
task.close_reason = comment or "abandoned"
elif feedback_type == "satisfied":
task.status = "closed"
task.closed_at = now
task.close_reason = "satisfied"
task.satisfaction = 1.0
task.updated_at = now
self.store.upsert_task(task)
self._event(task, f"feedback_{feedback_type}", run_id=run_id, payload=entry)
return task
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
task = self._require(task_id)
@ -267,3 +259,12 @@ def short_task_title(text: str) -> str:
if len(words) <= 4:
return cleaned[:40]
return " ".join(words[:4])[:40]
def normalize_acceptance_type(value: str) -> str:
normalized = (value or "").strip().lower()
if normalized == "satisfied":
return "accept"
if normalized not in {"accept", "revise", "abandon"}:
raise ValueError("acceptance_type must be one of: accept, revise, abandon")
return normalized

View File

@ -1,154 +0,0 @@
"""Automatic validation for internal Task mode."""
from __future__ import annotations
import json
from typing import Any
from beaver.engine.providers import ProviderBundle
from .models import TaskRecord, ValidationResult
class ValidationService:
async def validate_task_result(
self,
*,
task: TaskRecord,
user_message: str,
final_output: str,
evidence_packet: Any | None = None,
evidence_text: str = "",
transcript_excerpt: str = "",
tool_summaries: list[str] | None = None,
team_summaries: list[str] | None = None,
provider_bundle: ProviderBundle | None = None,
) -> ValidationResult:
provider = None
model = None
if provider_bundle is not None:
provider = provider_bundle.auxiliary_provider or provider_bundle.main_provider
runtime = provider_bundle.auxiliary_runtime or provider_bundle.main_runtime
model = getattr(runtime, "model", None)
if provider is not None:
try:
return await self._validate_with_provider(
provider=provider,
model=model,
task=task,
user_message=user_message,
final_output=final_output,
evidence_text=evidence_text,
transcript_excerpt=transcript_excerpt,
tool_summaries=tool_summaries or [],
team_summaries=team_summaries or [],
)
except Exception as exc:
return ValidationResult(
status="validator_error",
score=0.0,
issues=[f"Validator failed: {exc}"],
evidence_gaps=["Automatic validation failed before producing a reliable decision."],
missing_requirements=["User review is required because automatic validation failed."],
recommended_revision_prompt=(
"Review the answer and evidence, then decide whether to revise or accept it."
),
validator="llm_error",
)
return self._heuristic_validate(final_output)
async def _validate_with_provider(
self,
*,
provider: Any,
model: str | None,
task: TaskRecord,
user_message: str,
final_output: str,
evidence_text: str,
transcript_excerpt: str,
tool_summaries: list[str],
team_summaries: list[str],
) -> ValidationResult:
legacy_context = "" if evidence_text else (
f"Transcript excerpt:\n{transcript_excerpt}\n\n"
f"Tool summaries:\n{json.dumps(tool_summaries, ensure_ascii=False)}\n\n"
f"Team summaries:\n{json.dumps(team_summaries, ensure_ascii=False)}\n\n"
)
prompt = (
"Validate whether the assistant output satisfies the task. "
"Return only compact JSON with keys: passed, score, issues, "
"missing_requirements, recommended_revision_prompt.\n\n"
f"Task goal:\n{task.goal}\n\n"
f"Current user request:\n{user_message}\n\n"
f"Evidence packet:\n{evidence_text}\n\n"
f"{legacy_context}"
f"Assistant final output:\n{final_output}"
)
response = await provider.chat(
messages=[
{"role": "system", "content": "You are a strict task result validator."},
{"role": "user", "content": prompt},
],
tools=None,
model=model,
max_tokens=4096,
temperature=0.0,
)
payload = self._parse_json_object(response.content or "")
status = payload.get("status")
if status not in {"accepted", "rejected", "insufficient_evidence", "validator_error"}:
status = (
"accepted"
if payload.get("passed") and float(payload.get("score", 0.0) or 0.0) >= 0.75
else "rejected"
)
return ValidationResult(
status=status,
score=max(0.0, min(1.0, float(payload.get("score", 0.0) or 0.0))),
issues=[str(item) for item in payload.get("issues") or []],
missing_requirements=[str(item) for item in payload.get("missing_requirements") or []],
evidence_gaps=[str(item) for item in payload.get("evidence_gaps") or []],
recommended_revision_prompt=str(payload.get("recommended_revision_prompt") or ""),
validator="llm",
)
@staticmethod
def _heuristic_validate(final_output: str) -> ValidationResult:
text = final_output.strip()
if not text:
return ValidationResult(
passed=False,
score=0.0,
issues=["Assistant output is empty."],
missing_requirements=["A non-empty result is required."],
recommended_revision_prompt="Produce a complete, non-empty answer for the task.",
validator="heuristic",
)
lowered = text.lower()
if "run failed before completion" in lowered or "tool loop stopped" in lowered:
return ValidationResult(
passed=False,
score=0.35,
issues=["The run did not complete cleanly."],
missing_requirements=["A successful final result is required."],
recommended_revision_prompt="Retry the task and address the failure before returning the final answer.",
validator="heuristic",
)
return ValidationResult(passed=True, score=0.85, validator="heuristic")
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:].strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start >= 0 and end >= start:
cleaned = cleaned[start : end + 1]
payload = json.loads(cleaned)
if not isinstance(payload, dict):
raise ValueError("validator response must be a JSON object")
return payload

View File

@ -51,7 +51,7 @@ class WebFetchTool:
try:
safe_url = _safe_url(url)
limit = max(1000, min(int(max_chars or 12000), 50000))
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
response = await client.get(
safe_url,
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
@ -96,7 +96,7 @@ class WebSearchTool:
raise ValueError("query is required")
bounded = max(1, min(int(limit or 5), 10))
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
response.raise_for_status()
html = response.text

View File

@ -22,6 +22,23 @@ dependencies = [
dev = [
"pytest>=9.0.0,<10.0.0",
]
telegram = [
"python-telegram-bot>=22.0,<23.0",
]
feishu = [
"lark-oapi>=1.4.22,<2.0.0",
]
qqbot = [
"aiohttp>=3.9.0,<4.0.0",
]
weixin = [
"aiohttp>=3.9.0,<4.0.0",
]
channels = [
"python-telegram-bot>=22.0,<23.0",
"lark-oapi>=1.4.22,<2.0.0",
"aiohttp>=3.9.0,<4.0.0",
]
[project.scripts]
beaver = "beaver.interfaces.cli.main:main"

View File

@ -0,0 +1,47 @@
import asyncio
from contextlib import suppress
from typing import Any
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
return AgentRunResult(
session_id="web:test",
run_id=run_id,
output_text=output_text,
finish_reason="stop",
tool_iterations=0,
)
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
async def run_case() -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
calls: list[str] = []
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
calls.append(task)
if task == "outer":
return await loop.submit_direct("inner", session_id="web:test")
return _run_result(task, "inner completed")
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
loop_task = asyncio.create_task(loop.run())
await asyncio.sleep(0)
try:
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
finally:
await loop.stop()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(loop_task, timeout=1)
if not loop_task.done():
loop_task.cancel()
with suppress(asyncio.CancelledError):
await loop_task
assert result.output_text == "inner completed"
assert calls == ["outer", "inner"]
asyncio.run(run_case())

View File

@ -0,0 +1,84 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
created = client.post(
"/api/channel-connections",
json={
"kind": "telegram",
"mode": "polling",
"displayName": "Telegram Main",
"authType": "token",
"secrets": {"botToken": "token-1"},
"config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
},
)
assert created.status_code == 200
body = created.json()
connection_id = body["connection"]["connection_id"]
assert body["connection"]["kind"] == "telegram"
assert body["connection"]["status"] == "draft"
assert "credentials_ref" not in body["connection"]
assert body["connection"]["runtime_config"] == {
"max_message_chars": 4096,
"require_mention_in_groups": True,
}
assert body["credentials"] == {"botToken": "***"}
patched = client.patch(
f"/api/channel-connections/{connection_id}",
json={
"displayName": "Telegram Ops",
"config": {"maxMessageChars": 2048},
"secrets": {"botToken": "token-2"},
},
)
assert patched.status_code == 200
assert patched.json()["connection"]["display_name"] == "Telegram Ops"
assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
assert patched.json()["credentials"] == {"botToken": "***"}
listed = client.get("/api/channel-connections")
assert listed.status_code == 200
assert listed.json()[0]["connection_id"] == connection_id
assert "credentials_ref" not in listed.json()[0]
revoked = client.post(f"/api/channel-connections/{connection_id}/revoke")
assert revoked.status_code == 200
assert revoked.json()["connection"]["status"] == "revoked"
finally:
service.close()
def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
try:
with TestClient(app) as client:
response = client.get("/api/channel-connectors")
finally:
service.close()
assert response.status_code == 200
assert response.json() == [{"kind": "feishu"}, {"kind": "telegram"}, {"kind": "weixin"}]

View File

@ -0,0 +1,63 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
PairingTokenStore,
)
def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None:
store = ChannelConnectionStore(tmp_path / "connections.json")
created = store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:bot-main",
owner_user_id="user-1",
auth_type="token",
runtime_config={"max_message_chars": 4096},
capabilities=["receive_text", "send_text"],
)
updated = store.update_status(created.connection_id, status="connected", last_error=None)
revoked = store.revoke(created.connection_id)
assert created.connection_id
assert created.channel_id.startswith("telegram-")
assert created.status == "draft"
assert updated.status == "connected"
assert revoked.status == "revoked"
assert store.get(created.connection_id).status == "revoked"
assert [item.connection_id for item in store.list()] == [created.connection_id]
def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None:
store = CredentialStore(tmp_path / "credentials.json")
ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""})
assert ref.startswith("cred_")
assert store.get(ref) == {"botToken": "secret-token"}
assert store.redacted(ref) == {"botToken": "***"}
def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair")
consumed = store.consume(session.token, expected_kind="terminal")
reused = store.consume(session.token, expected_kind="terminal")
assert session.status == "pending"
assert consumed is not None
assert consumed.status == "consumed"
assert reused is None
def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None:
store = PairingTokenStore(tmp_path / "pairing.json")
session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair")
assert store.consume(session.token, expected_kind="weixin") is None

View File

@ -0,0 +1,164 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
ChannelRuntimeSpec,
CredentialStore,
ValidationResult,
)
class FakeConnector:
kind = "fake"
def __init__(self) -> None:
self.validated: list[str] = []
self.revoked: list[str] = []
async def validate(self, connection_id: str) -> ValidationResult:
self.validated.append(connection_id)
return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake")
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
)
async def revoke(self, connection_id: str) -> None:
self.revoked.append(connection_id)
return None
def test_connector_registry_dispatches_by_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
result = await registry.validate(connection.connection_id)
spec = await registry.materialize_runtime(connection.connection_id)
assert result.ok is True
assert connector.validated == [connection.connection_id]
assert spec.channel_id == "fake-channel"
asyncio.run(run())
def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
class CredentialAwareConnector(FakeConnector):
async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
stored = connection_store.get(connection_id)
return ChannelRuntimeSpec(
channel_id="fake-channel",
kind="fake",
mode="webhook",
account_id="fake-account",
display_name="Fake",
config={"enabled": True},
secrets_ref=stored.credentials_ref,
)
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(CredentialAwareConnector())
configs = await registry.materialize_channel_configs()
assert isinstance(configs["fake-channel"], ChannelConfig)
assert configs["fake-channel"].enabled is True
assert configs["fake-channel"].secrets == {"botToken": "token-1"}
asyncio.run(run())
def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(FakeConnector())
draft = connection_store.create(
kind="fake",
mode="webhook",
display_name="Draft",
account_id="draft",
owner_user_id=None,
auth_type="token",
)
connected = connection_store.create(
kind="fake",
mode="webhook",
display_name="Connected",
account_id="connected",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connected.connection_id, status="connected", last_error=None)
specs = await registry.materialize_connected_runtime_specs()
assert [spec.channel_id for spec in specs] == ["fake-channel"]
assert connection_store.get(draft.connection_id).status == "draft"
asyncio.run(run())
def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connector = FakeConnector()
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(connector)
connection = connection_store.create(
kind="fake",
mode="webhook",
display_name="Fake",
account_id="fake-account",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
await registry.revoke(connection.connection_id)
assert connector.revoked == [connection.connection_id]
assert connection_store.get(connection.connection_id).status == "revoked"
asyncio.run(run())

View File

@ -0,0 +1,414 @@
import asyncio
import json
from fastapi.testclient import TestClient
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import ChannelIdentity, InboundMessage, OutboundMessage
from beaver.foundation.events import MessageBus
from beaver.interfaces.channels.generic_webhook import GenericWebhookAdapter
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.interfaces.channels.state import ChannelDedupeStore, ChannelEventLog
from beaver.interfaces.web.app import _self_restart_enabled, create_app
from beaver.services.agent_service import AgentService
def test_channel_identity_builds_stable_session_id() -> None:
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo-user",
thread_id="main",
peer_type="dm",
message_id="msg-1",
)
assert identity.session_id() == "webhook-dev:local:demo-user:main"
assert identity.dedupe_key() == "webhook-dev:local:demo-user:main:msg-1"
def test_channel_identity_requires_routing_fields() -> None:
identity = ChannelIdentity(channel_id="webhook-dev", kind="webhook", account_id="", peer_id="demo")
assert identity.validation_error() == "account_id is required"
def test_messages_carry_channel_identity() -> None:
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo-user",
message_id="msg-1",
)
inbound = InboundMessage(channel="webhook-dev", content="hello", channel_identity=identity)
outbound = OutboundMessage(
channel="webhook-dev",
content="ok",
session_id=identity.session_id(),
finish_reason="stop",
channel_identity=identity,
)
assert inbound.channel_identity is identity
assert outbound.channel_identity is identity
def test_dedupe_store_tracks_processing_and_done(tmp_path) -> None:
store = ChannelDedupeStore(tmp_path / "dedupe.json", retention_hours=48)
created = store.mark_processing(
dedupe_key="webhook-dev:local:demo:msg-1",
session_id="webhook-dev:local:demo",
message_id="msg-1",
)
duplicate = store.mark_processing(
dedupe_key="webhook-dev:local:demo:msg-1",
session_id="webhook-dev:local:demo",
message_id="msg-1",
)
assert created.created is True
assert duplicate.created is False
assert duplicate.record is not None
assert duplicate.record["status"] == "processing"
store.mark_done(
dedupe_key="webhook-dev:local:demo:msg-1",
run_id="run-1",
reply="hello" * 10000,
max_reply_chars=20,
)
done = store.get("webhook-dev:local:demo:msg-1")
assert done is not None
assert done["status"] == "done"
assert done["reply"] == "hellohellohellohello"
def test_channel_event_log_writes_recent_events(tmp_path) -> None:
log = ChannelEventLog(tmp_path / "events.jsonl")
log.record(
channel_id="webhook-dev",
kind="inbound_accepted",
session_id="webhook-dev:local:demo",
message_id="msg-1",
status="ok",
text="hello world",
)
events = log.recent(channel_id="webhook-dev", limit=10)
assert len(events) == 1
assert events[0]["kind"] == "inbound_accepted"
assert events[0]["text_preview"] == "hello world"
assert "raw_channel_payload" not in json.dumps(events[0])
class FakeAgentService:
is_running = True
async def handle_inbound_message(self, inbound):
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
content=f"echo:{inbound.content}",
session_id=inbound.session_id,
finish_reason="stop",
run_id="run-1",
channel_identity=inbound.channel_identity,
)
class SlowFakeAgentService(FakeAgentService):
async def handle_inbound_message(self, inbound):
await asyncio.sleep(0.05)
return await super().handle_inbound_message(inbound)
def test_channel_runtime_accept_inbound_normalizes_session_and_dedupes(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=FakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
identity = ChannelIdentity(
channel_id="webhook-dev",
kind="webhook",
account_id="local",
peer_id="demo",
message_id="msg-1",
)
result = await runtime.accept_inbound(
InboundMessage(
channel="webhook-dev",
content="hello",
session_id="wrong",
channel_identity=identity,
)
)
duplicate = await runtime.accept_inbound(
InboundMessage(
channel="webhook-dev",
content="hello",
channel_identity=identity,
)
)
queued = await bus.consume_inbound()
assert result.accepted is True
assert queued.session_id == "webhook-dev:local:demo"
assert duplicate.accepted is False
assert duplicate.duplicate is True
asyncio.run(run())
def test_generic_webhook_adapter_waits_for_outbound_reply(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=FakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
adapter = GenericWebhookAdapter(
channel_id="webhook-dev",
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
inbound_sink=runtime,
response_timeout_seconds=1,
)
runtime.manager.register(adapter)
await runtime.start()
try:
response = await adapter.handle_webhook_payload(
{
"peer_id": "demo",
"message_id": "msg-1",
"text": "hello",
"peer_type": "dm",
}
)
finally:
await runtime.stop()
assert response["ok"] is True
assert response["reply"] == "echo:hello"
assert response["session_id"] == "webhook-dev:local:demo"
asyncio.run(run())
def test_generic_webhook_records_unclaimed_outbound_after_timeout(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(
service=SlowFakeAgentService(),
bus=bus,
workspace=tmp_path,
channels={},
)
adapter = GenericWebhookAdapter(
channel_id="webhook-dev",
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
inbound_sink=runtime,
response_timeout_seconds=1,
)
adapter.response_timeout_seconds = 0.01
runtime.manager.register(adapter)
await runtime.start()
try:
response = await adapter.handle_webhook_payload(
{
"peer_id": "demo",
"message_id": "msg-1",
"text": "hello",
"peer_type": "dm",
}
)
await asyncio.sleep(0.1)
events = runtime.recent_events("webhook-dev", limit=20)
finally:
await runtime.stop()
assert response["pending"] is True
assert any(event["kind"] == "outbound_unclaimed" for event in events)
asyncio.run(run())
def test_channel_runtime_starts_enabled_generic_webhook_and_reports_status(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"webhook-dev": ChannelConfig(
enabled=True,
kind="webhook",
mode="webhook",
account_id="local",
display_name="Webhook Dev",
config={"response_timeout_seconds": 1800},
),
"off": ChannelConfig(
enabled=False,
kind="webhook",
mode="webhook",
account_id="local",
),
},
)
await runtime.start()
try:
statuses = runtime.statuses()
finally:
await runtime.stop()
by_id = {item["channel_id"]: item for item in statuses}
assert by_id["webhook-dev"]["state"] == "running"
assert by_id["webhook-dev"]["webhook_url"] == "/api/channels/webhook-dev/webhook"
assert by_id["off"]["state"] == "disabled"
asyncio.run(run())
def test_channel_runtime_builds_platform_adapters_without_starting_networks(tmp_path) -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={},
)
cases = {
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
"feishu-main": ChannelConfig(enabled=True, kind="feishu", mode="websocket", account_id="tenant-main"),
"qq-main": ChannelConfig(enabled=True, kind="qqbot", mode="websocket", account_id="qq-main"),
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
}
for channel_id, cfg in cases.items():
adapter = runtime._build_adapter(channel_id, cfg)
assert adapter.channel_id == channel_id
assert adapter.kind == cfg.kind
assert adapter.mode == cfg.mode
def test_channel_runtime_reports_platform_capabilities(tmp_path) -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"telegram-main": ChannelConfig(enabled=True, kind="telegram", mode="polling", account_id="bot-main"),
"weixin-main": ChannelConfig(enabled=True, kind="weixin", mode="polling", account_id="wx-main"),
},
)
by_id = {item["channel_id"]: item for item in runtime.statuses()}
assert by_id["telegram-main"]["capabilities"] == [
"receive_text",
"send_text",
"receive_media",
"groups",
]
assert by_id["weixin-main"]["capabilities"] == [
"receive_text",
"send_text",
"receive_media",
"direct_messages",
]
def test_channel_runtime_platform_start_failure_does_not_stop_other_channels(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(
service=FakeAgentService(),
workspace=tmp_path,
channels={
"telegram-main": ChannelConfig(
enabled=True,
kind="telegram",
mode="polling",
account_id="bot-main",
secrets={},
),
"off": ChannelConfig(
enabled=False,
kind="weixin",
mode="polling",
account_id="wx-main",
),
},
)
await runtime.start()
try:
by_id = {item["channel_id"]: item for item in runtime.statuses()}
finally:
await runtime.stop()
assert by_id["telegram-main"]["state"] == "error"
assert "botToken" in by_id["telegram-main"]["last_error"]
assert by_id["off"]["state"] == "disabled"
asyncio.run(run())
def test_web_app_status_exposes_configured_channel(tmp_path) -> None:
config_path = tmp_path / "config.json"
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
"providers": {},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
payload = client.get("/api/status").json()
service.close()
assert payload["channels"][0]["channel_id"] == "webhook-dev"
assert payload["channels"][0]["state"] == "running"
assert payload["channels"][0]["webhook_url"] == "/api/channels/webhook-dev/webhook"
assert payload["runtime_controls"]["self_restart"] is True
def test_self_restart_env_defaults_enabled(monkeypatch) -> None:
monkeypatch.delenv("BEAVER_ENABLE_SELF_RESTART", raising=False)
assert _self_restart_enabled() is True
def test_self_restart_env_can_disable(monkeypatch) -> None:
monkeypatch.setenv("BEAVER_ENABLE_SELF_RESTART", "0")
assert _self_restart_enabled() is False

View File

@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime
class FakeService:
async def handle_inbound_message(self, inbound):
return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop")
def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
assert "webhook-dev" in runtime.adapters
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None:
async def run() -> None:
cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", cfg)
first = runtime.adapters["webhook-dev"]
await runtime.add_channel("webhook-dev", cfg)
assert runtime.adapters["webhook-dev"] is first
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None:
async def run() -> None:
good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel("webhook-dev", good)
old = runtime.adapters["webhook-dev"]
try:
await runtime.add_channel("webhook-dev", bad)
except ValueError:
pass
else:
raise AssertionError("Expected ValueError")
assert runtime.adapters["webhook-dev"] is old
assert runtime.channel_configs["webhook-dev"] == good
assert runtime.states["webhook-dev"]["state"] == "running"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None:
async def run() -> None:
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"webhook-dev",
ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
)
await runtime.remove_channel("webhook-dev")
assert "webhook-dev" not in runtime.adapters
assert "webhook-dev" not in runtime.manager.channels
assert runtime.states["webhook-dev"]["state"] == "removed"
finally:
await runtime.stop()
asyncio.run(run())
def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None:
async def run() -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
await runtime.start()
try:
await runtime.add_channel(
"weixin-main",
ChannelConfig(
enabled=True,
kind="external_connector",
mode="http",
account_id="weixin:me",
display_name="Weixin Main",
config={
"platformKind": "weixin",
"connectionId": "conn_1",
"sidecarBaseUrl": "http://external-connector:8787",
},
),
)
adapter = runtime.adapters["weixin-main"]
assert adapter.kind == "external_connector"
assert adapter.mode == "http"
assert getattr(adapter, "platform_kind") == "weixin"
finally:
await runtime.stop()
asyncio.run(run())

View File

@ -1,10 +1,13 @@
import json
import asyncio
from fastapi.testclient import TestClient
from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.engine.providers.litellm import LiteLLMProvider
from beaver.foundation.config import load_config
from beaver.interfaces.web.app import _reload_agent_config
from beaver.interfaces.web.app import create_app, _reload_agent_config
from beaver.services.agent_service import AgentService
@ -44,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"}
def test_config_loader_reads_channels(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
"config": {
"responseTimeoutSeconds": 1800,
"dedupeRetentionHours": 48,
},
"secrets": {"ignored_for_status": "secret-value"},
}
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
channel = config.channels["webhook-dev"]
assert channel.enabled is True
assert channel.kind == "webhook"
assert channel.mode == "webhook"
assert channel.account_id == "local"
assert channel.display_name == "Webhook Dev"
assert channel.config["response_timeout_seconds"] == 1800
assert channel.config["dedupe_retention_hours"] == 48
assert channel.secrets == {"ignored_for_status": "secret-value"}
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -161,6 +202,201 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close()
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
async def run_case() -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
await service.start()
class FailingMCPManager:
async def close(self) -> None:
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
loaded = service.create_loop().boot()
loaded.mcp_manager = FailingMCPManager()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
}
),
encoding="utf-8",
)
loop = asyncio.get_running_loop()
unhandled: list[dict[str, object]] = []
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
try:
_reload_agent_config(service, config_path)
await asyncio.sleep(0)
target = service.create_loop().boot().config.resolve_provider_target()
assert service.is_running is True
assert target["model"] == "new-model"
assert target["api_base"] == "https://new.example.com/v1"
assert unhandled == []
finally:
loop.set_exception_handler(previous_handler)
await service.shutdown(force=True)
asyncio.run(run_case())
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"maxTokens": 12345,
"temperature": 0.4,
"maxToolIterations": 9,
}
}
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
service = AgentService(config_path=config_path)
assert config.agents_defaults.max_tokens == 12345
assert config.agents_defaults.temperature == 0.4
assert config.agents_defaults.max_tool_iterations == 9
assert service.profile.max_tokens == 12345
assert service.profile.temperature == 0.4
assert service.profile.max_tool_iterations == 9
service.close()
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post(
"/api/agent-config",
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
)
status = client.get("/api/status")
saved = json.loads(config_path.read_text(encoding="utf-8"))
defaults = saved["agents"]["defaults"]
assert response.status_code == 200
assert response.json() == {"ok": True}
assert defaults["maxTokens"] == 8192
assert defaults["temperature"] == 0.6
assert defaults["maxToolIterations"] == 12
assert service.profile.max_tokens == 8192
assert service.profile.temperature == 0.6
assert service.profile.max_tool_iterations == 12
assert status.json()["max_tokens"] == 8192
assert status.json()["temperature"] == 0.6
assert status.json()["max_tool_iterations"] == 12
service.close()
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
config_path = tmp_path / "config.json"
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post(
"/api/agent-config",
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
)
config = load_config(config_path=config_path)
assert response.status_code == 200
assert config.agents_defaults.max_tokens is None
assert config.agents_defaults.temperature == 0
assert config.agents_defaults.max_tool_iterations == 0
assert service.profile.max_tokens is None
assert service.profile.temperature == 0
assert service.profile.max_tool_iterations == 0
service.close()
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {"botToken": "1234567890abcdef"},
"config": {"requireMentionInGroups": True},
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
before = client.get("/api/channels/telegram-main/config")
response = client.post(
"/api/channels/telegram-main/config",
json={
"enabled": True,
"kind": "telegram",
"mode": "polling",
"account_id": "bot-main",
"display_name": "Telegram Primary",
"secrets": {"botToken": ""},
"config": {
"requireMentionInGroups": False,
"allowFrom": ["1001", "1002"],
"maxMessageChars": 3000,
},
},
)
saved = json.loads(config_path.read_text(encoding="utf-8"))
channel = saved["channels"]["telegram-main"]
assert before.status_code == 200
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["restart_required"] is True
assert response.json()["channel"]["display_name"] == "Telegram Primary"
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
assert channel["enabled"] is True
assert channel["displayName"] == "Telegram Primary"
assert channel["secrets"]["botToken"] == "1234567890abcdef"
assert channel["config"]["allowFrom"] == ["1001", "1002"]
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from beaver.interfaces.channels.connections import MessageDedupeStore
def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.complete(first.dedupe_key, message_id="msg_1")
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert first.should_process is True
assert duplicate.should_process is False
assert duplicate.status == "completed"
assert duplicate.http_status == 200
def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert duplicate.should_process is False
assert duplicate.status == "processing"
assert duplicate.http_status == 409
assert duplicate.retry_after_seconds == 5
def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0)
store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert stale.should_process is True
assert stale.status == "processing"
assert stale.record.delivery_attempts == 2
def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None:
store = MessageDedupeStore(tmp_path / "message_dedupe.json")
first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
store.fail(first.dedupe_key, error="runtime rejected")
retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)
assert retry.should_process is True
assert retry.record.delivery_attempts == 2
assert retry.record.last_error is None

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from beaver.engine.context import ContextBuildInput, ContextBuilder, RuntimeContext, SessionContext
def test_context_builder_injects_current_date_and_time() -> None:
result = ContextBuilder().build_messages(
ContextBuildInput(
base_system_prompt="Follow user requests.",
current_user_input="今天几号?",
session_context=SessionContext(session_id="web:alpha", source="web", model="stub-model"),
runtime_context=RuntimeContext(
utc_datetime="2026-05-26T01:10:00+00:00",
local_datetime="2026-05-26T09:10:00+08:00",
timezone="Asia/Shanghai",
utc_offset="+08:00",
),
)
)
system_prompt = result.messages[0]["content"]
assert "# Current Date and Time" in system_prompt
assert "Current UTC time: 2026-05-26T01:10:00+00:00" in system_prompt
assert "Current local time: 2026-05-26T09:10:00+08:00" in system_prompt
assert "Local timezone: Asia/Shanghai" in system_prompt
assert "Local UTC offset: +08:00" in system_prompt
assert '"today", "tomorrow", "now", "this week", and "next month"' in system_prompt
assert result.messages[-1] == {"role": "user", "content": "今天几号?"}

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import ChannelConnectionStore
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
def _app(tmp_path, monkeypatch):
monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
return app, service
def _connected_connection(tmp_path):
state_dir = tmp_path / "state" / "channel_connections"
store = ChannelConnectionStore(state_dir / "connections.json")
connection = store.create(
kind="weixin",
mode="sidecar",
display_name="Weixin Main",
account_id="weixin:me",
owner_user_id=None,
auth_type="connector_session",
)
store.update_status(connection.connection_id, status="connected", last_error=None)
return connection
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
return {
"eventId": event_id,
"timestamp": "2026-06-02T09:30:00Z",
"deliveryAttempt": delivery_attempt,
"connectionId": connection.connection_id,
"channelId": connection.channel_id,
"kind": "weixin",
"accountId": "weixin:me",
"peerId": "peer-1",
"peerType": "dm",
"userId": "sender-1",
"threadId": None,
"messageId": "msg-1",
"messageType": "text",
"content": "hello",
"metadata": {},
}
def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
assert response.status_code == 200
assert response.json()["accepted"] is True
finally:
service.close()
def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
response = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer wrong"},
json=_payload(connection),
)
assert response.status_code == 401
finally:
service.close()
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
app, service = _app(tmp_path, monkeypatch)
try:
with TestClient(app) as client:
connection = _connected_connection(tmp_path)
first = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection),
)
second = client.post(
"/api/channel-connector-bridge/events",
headers={"Authorization": "Bearer bridge-token"},
json=_payload(connection, delivery_attempt=2),
)
assert first.status_code == 200
assert second.status_code in {200, 409}
assert second.json()["duplicate"] is True
finally:
service.close()

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id
class FakeSidecarClient:
def __init__(self) -> None:
self.sent: list[dict] = []
async def send(self, payload: dict) -> dict:
self.sent.append(payload)
return {"ok": True, "providerMessageId": "provider-1"}
def test_external_connector_channel_sends_with_target_and_request_id() -> None:
async def run() -> None:
client = FakeSidecarClient()
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=client,
)
message = OutboundMessage(
channel="weixin-main",
content="reply",
session_id="s1",
finish_reason="stop",
message_id="out-msg-1",
channel_identity=ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
thread_id=None,
user_id="sender-1",
message_id="in-msg-1",
),
metadata={"inbound_metadata": {"contextToken": "ctx-1"}},
)
await channel.send(message)
assert client.sent == [
{
"requestId": "out_weixin-main:s1:out-msg-1",
"connectionId": "conn_1",
"channelId": "weixin-main",
"kind": "weixin",
"target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
"content": "reply",
"metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1", "contextToken": "ctx-1"},
}
]
asyncio.run(run())
def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
identity = ChannelIdentity(
channel_id="weixin-main",
kind="weixin",
account_id="weixin:me",
peer_id="peer-1",
peer_type="dm",
message_id="in-msg-1",
)
first = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id=None, # type: ignore[arg-type]
channel_identity=identity,
)
second = OutboundMessage(
channel="weixin-main",
content="same reply",
session_id="s1",
finish_reason="stop",
message_id="",
channel_identity=identity,
)
assert _request_id(first) == _request_id(second)
assert _request_id(first).startswith("out_weixin-main:s1:")
def test_external_connector_channel_requires_identity() -> None:
async def run() -> None:
channel = ExternalConnectorChannel(
channel_id="weixin-main",
platform_kind="weixin",
connection_id="conn_1",
account_id="weixin:me",
display_name="Weixin Main",
sidecar_client=FakeSidecarClient(),
)
message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")
try:
await channel.send(message)
except ValueError as exc:
assert "channel_identity is required" in str(exc)
else:
raise AssertionError("Expected ValueError")
asyncio.run(run())

View File

@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
ChannelConnectorRegistry,
CredentialStore,
FeishuConnector,
WeixinConnector,
)
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class FakeSidecarClient:
def __init__(self) -> None:
self.sessions: dict[str, dict] = {}
self.started: list[dict] = []
self.logged_out: list[str] = []
async def start_session(self, payload: dict) -> dict:
self.started.append(payload)
session = {
"sessionId": "cs_1",
"kind": payload["kind"],
"status": "qr_ready",
"qrImage": "data:image/png;base64,abc",
"accountId": None,
"displayName": None,
"metadata": {},
}
self.sessions["cs_1"] = session
return session
async def get_session(self, session_id: str) -> dict:
return self.sessions[session_id]
async def logout(self, connection_id: str) -> dict:
self.logged_out.append(connection_id)
return {"ok": True}
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={})
assert view["sessionId"] == "cs_1"
assert view["connectionId"].startswith("conn_")
assert client.started[0]["kind"] == "weixin"
assert client.started[0]["connectionId"].startswith("conn_")
assert connection_store.list()[0].kind == "weixin"
assert connection_store.list()[0].status == "pairing"
asyncio.run(run())
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={})
connection = connection_store.list()[0]
client.sessions["cs_1"] = {
"sessionId": "cs_1",
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {"stateRef": "state-1"},
}
result = await connector.poll_session("cs_1")
updated = connection_store.get(connection.connection_id)
spec = await connector.materialize_runtime(connection.connection_id)
assert result["status"] == "connected"
assert updated.status == "connected"
assert updated.account_id == "weixin:me"
assert spec.kind == "external_connector"
assert spec.mode == "http"
assert spec.config["platformKind"] == "weixin"
assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787"
asyncio.run(run())
def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
client = FakeSidecarClient()
connector = FeishuConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"})
assert client.started[0]["kind"] == "feishu"
assert client.started[0]["options"] == {"domain": "feishu"}
asyncio.run(run())
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
config_path = tmp_path / "config.json"
config_path.write_text(
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
client = FakeSidecarClient()
try:
with TestClient(app) as http:
state_dir = tmp_path / "state" / "channel_connections"
connection_store = ChannelConnectionStore(state_dir / "connections.json")
credential_store = CredentialStore(state_dir / "credentials.json")
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
registry.register(
WeixinConnector(
connection_store=connection_store,
credential_store=credential_store,
sidecar_client=client,
sidecar_base_url="http://external-connector:8787",
)
)
app.state.channel_connector_registry = registry
started = http.post(
"/api/channel-connector-sessions",
json={"kind": "weixin", "displayName": "Weixin Main", "options": {}},
)
session_id = started.json()["session"]["sessionId"]
connection_id = started.json()["connection"]["connection_id"]
client.sessions[session_id] = {
"sessionId": session_id,
"kind": "weixin",
"status": "connected",
"accountId": "weixin:me",
"displayName": "Me",
"metadata": {},
}
polled = http.get(f"/api/channel-connector-sessions/{session_id}")
assert started.status_code == 200
assert polled.status_code == 200
assert polled.json()["connection"]["status"] == "connected"
assert connection_store.get(connection_id).status == "connected"
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
finally:
service.close()

View File

@ -0,0 +1,154 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeFeishuClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, receive_id_type: str, receive_id: str, text: str):
self.sent.append({"receive_id_type": receive_id_type, "receive_id": receive_id, "text": text})
def test_feishu_normalizes_direct_text_event() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "feishu-main:tenant-main:oc_chat"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "ou_user"
asyncio.run(run())
def test_feishu_group_mention_gate() -> None:
async def run() -> None:
sink = FakeSink()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={"requireMentionInGroups": True, "botOpenId": "ou_bot"},
client=FakeFeishuClient(),
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m2",
"chat_id": "oc_group",
"chat_type": "group",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
"mentions": [{"id": {"open_id": "ou_bot"}}],
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
assert len(sink.messages) == 1
asyncio.run(run())
def test_feishu_sends_text_to_chat_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeFeishuClient()
adapter = FeishuAdapter(
channel_id="feishu-main",
kind="feishu",
mode="websocket",
account_id="tenant-main",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "appSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"event": {
"message": {
"message_id": "m1",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"content": "{\"text\":\"hello\"}",
},
"sender": {"sender_id": {"open_id": "ou_user"}},
}
}
)
await adapter.send(
OutboundMessage(
channel="feishu-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent == [{"receive_id_type": "chat_id", "receive_id": "oc_chat", "text": "ok"}]
asyncio.run(run())

View File

@ -2,9 +2,10 @@ import asyncio
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway
from beaver.interfaces.channels.runtime import ChannelRuntime
from beaver.services.agent_service import AgentService
@ -18,8 +19,8 @@ class FakeResult:
model: str | None = "fake-model"
usage: dict[str, Any] = field(default_factory=dict)
task_id: str | None = "task-1"
task_status: str | None = "awaiting_feedback"
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
task_status: str | None = "awaiting_acceptance"
validation_result: dict[str, Any] | None = None
class FakeService:
@ -52,22 +53,15 @@ class InvalidService:
is_running = True
def test_gateway_routes_memory_channel_roundtrip() -> None:
def test_gateway_routes_memory_channel_roundtrip(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("hello", session_id="s1")
await channel.publish_text("hello", peer_id="s1", message_id="m1")
for _ in range(40):
if channel.sent_messages:
break
@ -76,37 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
assert channel.sent_messages
message = channel.sent_messages[0]
assert message.content == "echo:hello"
assert message.session_id == "s1"
assert message.session_id == "memory-dev:memory:s1"
assert message.finish_reason == "stop"
assert message.metadata["task_id"] == "task-1"
assert message.metadata["task_status"] == "awaiting_feedback"
assert message.metadata["validation_result"] == {"accepted": True}
assert message.metadata["task_status"] == "awaiting_acceptance"
assert message.metadata["evidence_status"] == "recorded"
assert message.metadata["validation_result"] is None
stop_event.set()
await asyncio.wait_for(task, timeout=2)
await runtime.stop()
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_channel_manager_dispatches_by_channel_id() -> None:
class CaptureChannel:
channel_id = "webhook-dev"
kind = "webhook"
mode = "webhook"
def __init__(self) -> None:
self.sent = []
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
self.sent.append(message)
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=SlowService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
channel = CaptureChannel()
manager = ChannelManager(bus)
manager.register(channel)
await bus.publish_outbound(
OutboundMessage(
channel="webhook-dev",
content="ok",
session_id="webhook-dev:local:demo",
finish_reason="stop",
)
)
await channel.publish_text("slow", session_id="s1")
await asyncio.sleep(0.05)
stop_event = asyncio.Event()
stop_event.set()
await asyncio.wait_for(task, timeout=3)
await manager.dispatch_outbound(stop_event)
assert channel.sent[0].content == "ok"
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
runtime = ChannelRuntime(service=SlowService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(runtime)
runtime.manager.register(channel)
await runtime.start()
await channel.publish_text("slow", peer_id="s1", message_id="m1")
for _ in range(40):
if any(event["kind"] == "direct_run_started" for event in runtime.events.recent(limit=20)):
break
await asyncio.sleep(0.05)
await runtime.stop()
assert channel.sent_messages
assert channel.sent_messages[0].finish_reason == "cancelled"
@ -117,13 +147,27 @@ def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
async def run() -> None:
bus = MessageBus()
class CaptureChannel:
channel_id = "memory-dev"
kind = "memory"
mode = "webhook"
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
pass
try:
await run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channel_manager=ChannelManager(bus),
channels=[MemoryChannelAdapter(bus)],
channels=[CaptureChannel()],
stop_event=asyncio.Event(),
)
except ValueError as exc:
@ -211,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
asyncio.run(run())
def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_memory_channel_adapts_payload_to_channel_identity_session_id(tmp_path) -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus, name="telegram")
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
channel = MemoryChannelAdapter(
runtime,
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
)
inbound = await channel.publish_external_text(
"hello",
chat_id="chat-1",
@ -224,8 +274,10 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
queued = await bus.consume_inbound()
assert queued is inbound
assert queued.channel == "telegram"
assert queued.session_id == "telegram:chat-1"
assert queued.channel == "telegram-main"
assert queued.session_id == "telegram-main:bot-main:chat-1"
assert queued.channel_identity is not None
assert queued.channel_identity.kind == "telegram"
assert queued.metadata["chat_id"] == "chat-1"
assert queued.metadata["message_id"] == "message-1"
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
@ -235,7 +287,9 @@ def test_memory_channel_adapts_old_style_payload_to_stable_session_id() -> None:
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel:
name = "started"
channel_id = "started"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
@ -251,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
pass
class BlockingChannel:
name = "blocking"
channel_id = "blocking"
kind = "memory"
mode = "webhook"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus

View File

@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
def test_platform_channel_modules_import_without_live_clients() -> None:
from beaver.interfaces.channels.platforms.feishu import FeishuAdapter
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
assert FeishuAdapter.KIND == "feishu"
assert QQBotAdapter.KIND == "qqbot"
assert TelegramAdapter.KIND == "telegram"
assert WeixinAdapter.KIND == "weixin"
def test_platform_channel_optional_extras_are_declared() -> None:
import tomllib
from pathlib import Path
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
extras = data["project"]["optional-dependencies"]
assert "python-telegram-bot>=22.0,<23.0" in extras["telegram"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["feishu"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["qqbot"]
assert "aiohttp>=3.9.0,<4.0.0" in extras["weixin"]
assert "python-telegram-bot>=22.0,<23.0" in extras["channels"]
assert "lark-oapi>=1.4.22,<2.0.0" in extras["channels"]
def test_agent_loop_boots(tmp_path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
loaded = loop.boot()
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
def test_channel_imports() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
class Sink:
async def accept_inbound(self, message):
await bus.publish_inbound(message)
channel = MemoryChannelAdapter(Sink())
manager = ChannelManager(bus)
manager.register(channel)
assert manager.channels["memory"] is channel
assert manager.channels["memory-dev"] is channel
def test_web_schema_imports() -> None:

View File

@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
from beaver.engine import EngineLoader
from beaver.skills.catalog.utils import parse_frontmatter
REPO_ROOT = Path(__file__).resolve().parents[4]
EXPECTED_INITIAL_SKILL_TOOLS = {
"cron-scheduler": ["cron"],
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
"memory-management": ["memory"],
"outlook-mail": [
"mcp_outlook_mcp_mail_list_folders",
"mcp_outlook_mcp_mail_list_messages",
"mcp_outlook_mcp_mail_search_messages",
"mcp_outlook_mcp_mail_get_message",
"mcp_outlook_mcp_mail_send_email",
"mcp_outlook_mcp_mail_reply_to_message",
"mcp_outlook_mcp_mail_forward_message",
"mcp_outlook_mcp_mail_move_message",
"mcp_outlook_mcp_mail_delta_sync",
"mcp_outlook_mcp_calendar_list_events",
"mcp_outlook_mcp_calendar_create_event",
"mcp_outlook_mcp_calendar_update_event",
"mcp_outlook_mcp_calendar_get_schedule",
"mcp_outlook_mcp_calendar_find_meeting_times",
"mcp_outlook_mcp_calendar_delta_sync",
],
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
"terminal-operation": ["terminal", "process", "execute_code"],
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
"web-operation": ["web_fetch", "web_search"],
}
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
assert frontmatter["tools"] == expected_tools
assert version["frontmatter"]["tools"] == expected_tools
assert version["tool_hints"] == expected_tools
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
loaded = EngineLoader(workspace=tmp_path).load()
try:
assert "skill_view" in loaded.tools
assert loaded.tool_registry is not None
assert loaded.tool_registry.get("skill_view") is not None
finally:
loaded.close()

View File

@ -113,6 +113,19 @@ def test_litellm_provider_preserves_reasoning_content_for_tool_round_trip() -> N
assert LiteLLMProvider._sanitize_messages(messages)[0]["reasoning_content"] == "must be passed back"
def test_litellm_provider_merges_late_system_messages_to_front() -> None:
messages = [
{"role": "system", "content": "base"},
{"role": "user", "content": "question"},
{"role": "system", "content": "finalize without tools"},
]
sanitized = LiteLLMProvider._sanitize_messages(messages)
assert [message["role"] for message in sanitized] == ["system", "user"]
assert sanitized[0]["content"] == "base\n\nfinalize without tools"
def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}

View File

@ -79,7 +79,7 @@ def _task() -> TaskRecord:
goal="实现任务连续性",
constraints=[],
priority=0,
status="awaiting_feedback",
status="awaiting_acceptance",
creator="test",
created_at="now",
updated_at="now",

View File

@ -0,0 +1,64 @@
import asyncio
from types import SimpleNamespace
from beaver.engine.loop import AgentProfile
from beaver.engine.providers.anthropic import AnthropicProvider
from beaver.engine.providers.litellm import LiteLLMProvider
def test_agent_profile_uses_provider_output_default() -> None:
assert AgentProfile().max_tokens is None
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
captured_kwargs: dict = {}
async def fake_acompletion(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content="ok", tool_calls=[]),
finish_reason="stop",
)
],
usage=None,
)
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
async def run_case():
provider = LiteLLMProvider(default_model="openai/gpt-test")
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
response = asyncio.run(run_case())
assert response.content == "ok"
assert "max_tokens" not in captured_kwargs
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
captured_kwargs: dict = {}
class FakeMessages:
async def create(self, **kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(
content=[SimpleNamespace(type="text", text="ok")],
usage=None,
stop_reason="stop",
)
class FakeClient:
messages = FakeMessages()
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
async def run_case():
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
response = asyncio.run(run_case())
assert response.content == "ok"
assert captured_kwargs["max_tokens"] == 64_000

View File

@ -35,6 +35,7 @@ class StubProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
if not self._responses:
raise AssertionError("No stubbed provider responses left")
@ -47,11 +48,22 @@ class StubProvider(LLMProvider):
class StubSkillAssembler:
def __init__(self, activated_skills: list[SkillContext]) -> None:
self.activated_skills = activated_skills
self.calls: list[dict] = []
async def assemble(self, **kwargs) -> SkillAssemblyResult:
self.calls.append(kwargs)
return SkillAssemblyResult(activated_skills=list(self.activated_skills))
class RecordingToolAssembler:
def __init__(self) -> None:
self.calls: list[dict] = []
async def assemble(self, **kwargs):
self.calls.append(kwargs)
return kwargs["registry"].get_specs(["memory"])
def _tool_call(*, name: str = "echo", arguments: dict | None = None, call_id: str = "call-1") -> SimpleNamespace:
return SimpleNamespace(
id=call_id,
@ -576,6 +588,48 @@ def test_agent_loop_records_skill_receipts_and_effects(tmp_path: Path) -> None:
assert effect_records[-1].run_id == result.run_id
def test_thinking_disabled_still_uses_skill_and_tool_assembly(tmp_path: Path) -> None:
skill = SkillContext(
name="docker-debug",
content="Use docker logs before editing config.",
version="v0007",
content_hash="hash-v7",
activation_reason="llm_selected",
tool_hints=["terminal"],
)
skill_assembler = StubSkillAssembler([skill])
tool_assembler = RecordingToolAssembler()
loader = EngineLoader(
workspace=tmp_path,
skill_assembler=skill_assembler,
tool_assembler=tool_assembler,
)
loop = AgentLoop(loader=loader)
bundle = ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider(
[LLMResponse(content="Done", finish_reason="stop", provider_name="stub", model="stub-model")]
),
)
result = asyncio.run(
loop.process_direct(
"Why is the Docker container crashing?",
provider_bundle=bundle,
thinking_enabled=False,
)
)
loaded = loop.boot()
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
tool_selection = next(event for event in events if event.event_type == "tool_selection_snapshotted")
assert skill_assembler.calls
assert skill_assembler.calls[0]["thinking_enabled"] is False
assert tool_assembler.calls
assert [skill.name for skill in tool_assembler.calls[0]["activated_skills"]] == ["docker-debug"]
assert tool_selection.event_payload["tool_names"] == ["memory"]
def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path: Path) -> None:
skill = SkillContext(
name="docker-debug",
@ -635,6 +689,52 @@ def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path:
assert effect_records[-1].success is False
def test_agent_loop_suppresses_raw_tool_call_when_finalizing_after_tool_limit(tmp_path: Path) -> None:
loader = EngineLoader(
workspace=tmp_path,
skill_assembler=StubSkillAssembler([]),
)
loop = AgentLoop(loader=loader)
bundle = ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider(
[
LLMResponse(
content="Need a tool.",
finish_reason="tool_calls",
tool_calls=[_tool_call()],
provider_name="stub",
model="stub-model",
),
LLMResponse(
content=(
"<tool_call>\n"
"<function=mcp_local_web_mcp_web_fetch>\n"
"<parameter=url>https://example.com</parameter>\n"
"</function>\n"
"</tool_call>"
),
finish_reason="stop",
provider_name="stub",
model="stub-model",
),
]
),
)
result = asyncio.run(
loop.process_direct(
"Fetch the latest result",
provider_bundle=bundle,
max_tool_iterations=0,
)
)
assert result.finish_reason == "max_tool_iterations"
assert "<tool_call>" not in result.output_text
assert "raw tool call was suppressed" in result.output_text
def test_llm_request_snapshot_defaults_to_compact_payload(tmp_path: Path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path, skill_assembler=StubSkillAssembler([])))
bundle = ProviderBundle(

View File

@ -0,0 +1,66 @@
from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.platforms.base import (
chunk_text,
compact_media_summary,
config_bool,
config_list,
outbound_target,
)
def test_config_helpers_normalize_common_values() -> None:
assert config_bool({"enabled": "true"}, "enabled", default=False) is True
assert config_bool({"enabled": "0"}, "enabled", default=True) is False
assert config_list({"allowFrom": "u1,u2"}, "allowFrom") == ["u1", "u2"]
assert config_list({"allowFrom": ["u1", 2]}, "allowFrom") == ["u1", "2"]
def test_chunk_text_preserves_order_and_limit() -> None:
chunks = chunk_text("abcdef", max_chars=2)
assert chunks == ["ab", "cd", "ef"]
def test_outbound_target_prefers_channel_identity() -> None:
identity = ChannelIdentity(
channel_id="telegram-main",
kind="telegram",
account_id="bot-main",
peer_id="chat-1",
thread_id="topic-1",
peer_type="group",
user_id="user-1",
)
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="ignored",
finish_reason="stop",
channel_identity=identity,
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
assert target.peer_type == "group"
assert target.user_id == "user-1"
def test_outbound_target_falls_back_to_session_id() -> None:
message = OutboundMessage(
channel="telegram-main",
content="ok",
session_id="telegram-main:bot-main:chat-1:topic-1",
finish_reason="stop",
)
target = outbound_target(message)
assert target.peer_id == "chat-1"
assert target.thread_id == "topic-1"
def test_compact_media_summary_mentions_attachment_type() -> None:
assert compact_media_summary("photo", file_name="cat.png") == "[photo: cat.png]"
assert compact_media_summary("document") == "[document]"

View File

@ -5,6 +5,7 @@ from pathlib import Path
from beaver.engine.session import SessionManager
from beaver.memory.runs import RunMemoryStore, RunRecord
from beaver.services.process_service import SessionProcessProjector
from beaver.skills.specs import SkillActivationReceipt
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
@ -101,12 +102,23 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
"web:test",
run_id="main-run",
role="system",
event_type="task_validation_snapshotted",
event_type="task_evidence_recorded",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"validation_result": {"accepted": True, "score": 0.9},
"retry_scheduled": False,
"evidence_status": "recorded",
},
context_visible=False,
)
session.append_message(
"web:test",
run_id="main-run",
role="system",
event_type="task_acceptance_recorded",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"acceptance_type": "accept",
},
context_visible=False,
)
@ -121,9 +133,235 @@ def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
assert sub_run["metadata"]["selected_skill_names"] == ["research-workflow"]
assert sub_run["metadata"]["skill_query"] == "research workflow"
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
assert any(event["actor_name"] == "Validator" for event in projection["events"])
assert any(event["actor_name"] == "Evidence" for event in projection["events"])
assert any(run["session_id"] == "web:test" for run in projection["runs"])
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
assert planned_event["metadata"]["timeline_type"] == "plan"
assert planned_event["metadata"]["plan_mode"] == "team"
assert planned_event["metadata"]["strategy"] == "sequence"
assert planned_event["metadata"]["selected_skill_names"] == ["research-workflow"]
skill_event = next(event for event in projection["events"] if event["kind"] == "skill_selected")
assert skill_event["metadata"]["timeline_type"] == "skill"
assert skill_event["metadata"]["skill_names"] == ["research-workflow"]
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
assert team_event["metadata"]["timeline_type"] == "agent_team"
assert team_event["metadata"]["team_run_ids"] == ["sub-run"]
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
assert node_event["metadata"]["timeline_type"] == "agent_progress"
assert "node_result" not in node_event["metadata"]
evidence_event = next(event for event in projection["events"] if event["kind"] == "task_result_ready")
assert evidence_event["metadata"]["timeline_type"] == "result"
assert evidence_event["status"] == "done"
acceptance_event = next(event for event in projection["events"] if event["kind"] == "task_acceptance_recorded")
assert acceptance_event["metadata"]["timeline_type"] == "acceptance"
def test_process_projection_maps_failed_task_team_events(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="failed-sub-run",
session_id="failed-sub-session",
task_id="task-1",
attempt_index=1,
task_text="failed sub task",
started_at="2026-01-01T00:00:01+00:00",
ended_at="2026-01-01T00:00:02+00:00",
success=False,
finish_reason="error",
)
)
session.append_message(
"web:test",
role="system",
event_type="task_team_run_failed",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"team_success": False,
"team_run_ids": ["failed-sub-run"],
"error": "research node failed",
"node_results": [
{
"node_id": "research",
"success": False,
"error": "source unavailable",
"run_id": "failed-sub-run",
"finish_reason": "error",
}
],
},
context_visible=False,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
team_event = next(event for event in projection["events"] if event["kind"] == "agent_team_created")
assert team_event["status"] == "error"
assert team_event["metadata"]["timeline_type"] == "agent_team"
assert team_event["metadata"]["team_run_ids"] == ["failed-sub-run"]
node_event = next(event for event in projection["events"] if event["kind"] == "agent_finished")
assert node_event["status"] == "error"
assert node_event["metadata"]["timeline_type"] == "agent_progress"
def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"plan_mode": None,
"strategy": None,
},
context_visible=False,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
root_run = next(run for run in projection["runs"] if run["run_id"] == "task:task-1:attempt:1")
assert root_run["metadata"]["plan_mode"] == "single"
assert root_run["metadata"]["strategy"] == "single"
planned_event = next(event for event in projection["events"] if event["kind"] == "task_planned")
assert planned_event["metadata"]["plan_mode"] == "single"
assert planned_event["metadata"]["strategy"] == "single"
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="main-run",
session_id="web:test",
task_id="task-1",
attempt_index=1,
task_text="main task",
started_at="2026-01-01T00:00:03+00:00",
ended_at="2026-01-01T00:00:04+00:00",
success=True,
finish_reason="stop",
activated_skills=[
SkillActivationReceipt(
run_id="main-run",
session_id="web:test",
skill_name="web-operation",
skill_version="1",
content_hash="hash",
activated_at="2026-01-01T00:00:03+00:00",
activation_reason="Needs live web lookup.",
)
],
)
)
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"plan_mode": "single",
"strategy": "single",
"selected_skill_names": [],
},
context_visible=False,
)
session.append_message(
"web:test",
role="system",
event_type="task_synthesis_completed",
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
context_visible=False,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
skill_events = [
event
for event in projection["events"]
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
]
assert skill_events
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="main-run",
session_id="web:test",
task_id="task-1",
attempt_index=1,
task_text="main task",
started_at="2026-01-01T00:00:03+00:00",
ended_at="2026-01-01T00:00:04+00:00",
success=True,
finish_reason="stop",
)
)
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={"task_id": "task-1", "attempt_index": 1},
context_visible=False,
)
session.append_message(
"web:test",
run_id="main-run",
role="assistant",
event_type="assistant_message_added",
event_payload={"task_id": "task-1"},
content="Searching",
tool_calls=[
{
"id": "call-1",
"name": "multi_search",
"arguments": {"query": "Macau cafe near Bóvia"},
}
],
context_visible=False,
)
session.append_message(
"web:test",
run_id="main-run",
role="tool",
event_type="tool_result_recorded",
event_payload={"success": True, "error": None},
content="Found 3 restaurants",
tool_name="multi_search",
tool_call_id="call-1",
context_visible=True,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
assert tool_call["metadata"]["timeline_type"] == "tool_call"
assert tool_call["metadata"]["tool_name"] == "multi_search"
assert tool_call["run_id"] == "main-run"
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
assert tool_result["metadata"]["timeline_type"] == "tool_result"
assert tool_result["metadata"]["tool_name"] == "multi_search"
assert tool_result["metadata"]["success"] is True
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
session = SessionManager(tmp_path)

View File

@ -0,0 +1,143 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.qqbot import QQBotAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeQQBotClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_type: str, peer_id: str, content: str, message_id: str | None):
self.sent.append(
{
"peer_type": peer_type,
"peer_id": peer_id,
"content": content,
"message_id": message_id,
}
)
def test_qqbot_normalizes_private_c2c_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "C2C_MESSAGE_CREATE",
"d": {
"id": "m1",
"author": {"user_openid": "u1"},
"content": "hello",
},
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "qq-main:qq-bot:u1"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_normalizes_group_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=FakeQQBotClient(),
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
message = sink.messages[0]
assert message.session_id == "qq-main:qq-bot:g1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "u1"
asyncio.run(run())
def test_qqbot_sends_reply_with_original_message_id() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeQQBotClient()
adapter = QQBotAdapter(
channel_id="qq-main",
kind="qqbot",
mode="websocket",
account_id="qq-bot",
display_name=None,
inbound_sink=sink,
secrets={"appId": "app", "clientSecret": "secret"},
config={},
client=client,
)
await adapter.handle_event_payload(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"id": "m2",
"group_openid": "g1",
"author": {"member_openid": "u1"},
"content": "hello group",
},
}
)
await adapter.send(
OutboundMessage(
channel="qq-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert client.sent[0] == {
"peer_type": "group",
"peer_id": "g1",
"content": "ok",
"message_id": "m2",
}
asyncio.run(run())

View File

@ -4,23 +4,17 @@ import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode
from beaver.engine import EngineLoader
from beaver.engine.context.builder import ContextBuilder, ContextBuildInput
from beaver.engine.providers.base import LLMProvider, LLMResponse
from beaver.engine.providers.factory import ProviderBundle
from beaver.services.agent_service import AgentService
from beaver.skills.assembler import SkillAssemblyResult
from beaver.tasks import TaskExecutionPlan, TaskRecord, TaskService, ValidationResult, ValidationService
from beaver.tasks import TaskExecutionPlan, TaskService
class StubProvider(LLMProvider):
def __init__(self, responses: list[LLMResponse]) -> None:
super().__init__()
self._responses = list(responses)
self.calls: list[dict[str, object]] = []
async def chat(
self,
@ -30,7 +24,6 @@ class StubProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
) -> LLMResponse:
self.calls.append({"messages": messages, "tools": tools, "model": model})
if not self._responses:
raise AssertionError("No stubbed provider responses left")
return self._responses.pop(0)
@ -39,30 +32,9 @@ class StubProvider(LLMProvider):
return "stub-model"
class StubValidationService:
def __init__(self, results: list[ValidationResult]) -> None:
self.results = list(results)
self.calls: list[dict] = []
async def validate_task_result(self, **kwargs) -> ValidationResult:
self.calls.append(kwargs)
if not self.results:
raise AssertionError("No stubbed validation results left")
return self.results.pop(0)
class StubTaskExecutionPlanner:
def __init__(self, plans: list[TaskExecutionPlan] | None = None) -> None:
self.plans = list(plans or [TaskExecutionPlan.single("test-single")])
self.calls = []
async def plan(self, **kwargs) -> TaskExecutionPlan:
self.calls.append(kwargs)
if len(self.plans) == 1:
return self.plans[0]
if not self.plans:
raise AssertionError("No stubbed execution plans left")
return self.plans.pop(0)
return TaskExecutionPlan.single("test-single")
class FakeLearningCandidate:
@ -70,15 +42,6 @@ class FakeLearningCandidate:
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
class RecordingSkillAssembler:
def __init__(self) -> None:
self.task_descriptions: list[str] = []
async def assemble(self, **kwargs) -> SkillAssemblyResult:
self.task_descriptions.append(kwargs["task_description"])
return SkillAssemblyResult()
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
return LLMResponse(
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
@ -107,828 +70,157 @@ def _bundle(*responses: str, route_action: str = "new_task") -> ProviderBundle:
)
def _single_planner() -> StubTaskExecutionPlanner:
return StubTaskExecutionPlanner([TaskExecutionPlan.single("test-single")])
def _team_plan(strategy: str = "sequence") -> TaskExecutionPlan:
return TaskExecutionPlan(
mode="team",
reason="test-team",
graph=ExecutionGraph(
strategy=strategy, # type: ignore[arg-type]
nodes=[
ExecutionNode(
node_id="research",
task="research implementation options",
agent=AgentDescriptor(name="researcher", role="research"),
)
],
),
final_synthesis_instruction="Use the sub-agent result to produce the final answer.",
)
def _provider_bundle(provider: StubProvider) -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=provider,
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
auxiliary_provider=StubProvider([_route_response("new_task")]),
)
def _main_only_bundle(*responses: str) -> ProviderBundle:
return ProviderBundle(
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
main_provider=StubProvider(
[
LLMResponse(
content=response,
finish_reason="stop",
provider_name="stub",
model="stub-model",
)
for response in responses
]
),
)
def _task_record(status: str) -> TaskRecord:
return TaskRecord(
task_id="task-1",
session_id="session-1",
description="test task",
goal="test task",
constraints=[],
priority=0,
status=status,
creator="main-agent",
created_at="2026-05-22T00:00:00+00:00",
updated_at="2026-05-22T00:00:00+00:00",
)
def test_simple_question_does_not_create_task(tmp_path: Path) -> None:
def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService([]),
task_execution_planner=StubTaskExecutionPlanner(),
)
)
result = asyncio.run(
service.process_direct(
"hello?",
session_id="web:simple",
provider_bundle=_bundle("hi", route_action="simple_chat"),
)
)
loaded = service.create_loop().boot()
assert result.task_id is None
assert loaded.task_service.store.list_tasks() == []
def test_complex_request_creates_task_and_records_validation(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[ValidationResult(passed=True, score=0.9, validator="test")]
),
"draft release notes",
session_id="web:test",
provider_bundle=_bundle("Done"),
)
)
result = asyncio.run(
service.process_direct(
"implement the new report workflow",
session_id="web:task",
provider_bundle=_bundle("implemented"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task_by_run_id(result.run_id)
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
run_record = loaded.run_memory_store.list_runs()[-1]
skill_effects = next(event for event in events if event.event_type == "skill_effects_snapshotted")
assert result.task_id is not None
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(result.task_id or "")
assert task is not None
assert task.status == "awaiting_feedback"
assert any(event.event_type == "task_validation_snapshotted" for event in events)
assert run_record.task_id == result.task_id
assert run_record.validation_result["accepted"] is True
assert skill_effects.event_payload["candidate_generation_allowed"] is False
assert skill_effects.event_payload["learning_candidates"] == []
assert task.metadata["short_title"] == "Test task"
assert task.status == "awaiting_acceptance"
assert task.validation_result is None
assert result.validation_result is None
event_types = [event.event_type for event in task_service.list_events(task.task_id)]
assert "evidence_recorded" in event_types
assert "validated" not in event_types
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
skill_assembler = RecordingSkillAssembler()
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[ValidationResult(passed=True, score=1.0, validator="test")]
),
skill_assembler=skill_assembler,
)
)
result = asyncio.run(
service.process_direct(
"继续按刚才的方案改",
session_id="web:task-skill-query",
provider_bundle=_bundle("done", route_action="new_task"),
)
)
assert result.task_id
assert skill_assembler.task_descriptions
query = skill_assembler.task_descriptions[0]
assert "Task goal:" in query
assert "Current user request:" in query
assert "Previously activated skills:" in query
assert "If no published skill matches, return []" in query
def test_active_task_continues_until_llm_closes_it(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(passed=True, score=0.9, validator="test"),
ValidationResult(passed=True, score=0.9, validator="test"),
]
),
)
)
first = asyncio.run(
service.process_direct(
"implement the search workflow",
session_id="web:continue",
provider_bundle=_bundle("first done", route_action="new_task"),
)
)
second = asyncio.run(
service.process_direct(
"also add tests for it",
session_id="web:continue",
provider_bundle=_bundle("tests added", route_action="continue_task"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(first.task_id)
assert task is not None
assert second.task_id == first.task_id
assert len(task.run_ids) == 2
closed = asyncio.run(
service.process_direct(
"这个任务结束了",
session_id="web:continue",
provider_bundle=_bundle("好的,已结束。", route_action="close_task"),
)
)
task = loaded.task_service.get_task(first.task_id)
assert closed.task_id is None
assert task is not None
assert task.status == "closed"
assert loaded.task_service.active_task_view("web:continue") is None
def test_active_task_revision_input_records_feedback_and_reruns(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(passed=True, score=0.9, validator="test"),
ValidationResult(passed=True, score=0.95, validator="test"),
]
),
)
)
first = asyncio.run(
service.process_direct(
"查询珠海天气",
session_id="web:revise-direct",
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
)
)
second = asyncio.run(
service.process_direct(
"再详细一点,并加上明后天穿衣建议",
session_id="web:revise-direct",
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(first.task_id)
messages = loaded.session_manager.get_messages_as_conversation(first.session_id)
first_assistant = [
message
for message in messages
if message.get("role") == "assistant" and message.get("run_id") == first.run_id
][-1]
user_messages = [message.get("content") for message in messages if message.get("role") == "user"]
assert second.task_id == first.task_id
assert task is not None
assert task.status == "awaiting_feedback"
assert len(task.run_ids) == 2
assert task.feedback == [
{
"feedback_type": "revise",
"comment": "再详细一点,并加上明后天穿衣建议",
"run_id": first.run_id,
"created_at": task.feedback[0]["created_at"],
}
]
assert first_assistant["feedback_state"] == "revise"
assert "再详细一点,并加上明后天穿衣建议" in user_messages
def test_explicit_revision_feedback_then_input_reruns_without_duplicate_feedback(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(passed=True, score=0.9, validator="test"),
ValidationResult(passed=True, score=0.95, validator="test"),
]
),
)
)
first = asyncio.run(
service.process_direct(
"查询珠海天气",
session_id="web:explicit-revise",
provider_bundle=_bundle("珠海天气概览", route_action="new_task"),
)
)
feedback = asyncio.run(
service.submit_feedback(
session_id=first.session_id,
run_id=first.run_id,
feedback_type="revise",
comment="准备补充穿衣建议",
)
)
second = asyncio.run(
service.process_direct(
"加上明后天穿衣建议",
session_id="web:explicit-revise",
provider_bundle=_bundle("更新后的珠海天气和穿衣建议", route_action="revise_task"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(first.task_id)
assert feedback["task_status"] == "needs_revision"
assert second.task_id == first.task_id
assert task is not None
assert task.status == "awaiting_feedback"
assert len(task.run_ids) == 2
assert len(task.feedback) == 1
assert task.feedback[0]["feedback_type"] == "revise"
assert task.feedback[0]["comment"] == "准备补充穿衣建议"
def test_validation_result_status_drives_accepted_and_passed() -> None:
accepted = ValidationResult(status="accepted", score=0.9, validator="test")
insufficient = ValidationResult(status="insufficient_evidence", score=0.9, validator="test")
rejected = ValidationResult(status="rejected", score=0.9, validator="test")
assert accepted.passed is True
assert accepted.accepted is True
assert insufficient.passed is False
assert insufficient.accepted is False
assert rejected.passed is False
assert rejected.accepted is False
def test_validation_result_from_legacy_payload_maps_to_status() -> None:
accepted = ValidationResult.from_dict({"passed": True, "score": 0.9, "validator": "legacy"})
low_score = ValidationResult.from_dict({"passed": True, "score": 0.7, "validator": "legacy"})
rejected = ValidationResult.from_dict({"passed": False, "score": 0.2, "validator": "legacy"})
assert accepted is not None
assert accepted.status == "accepted"
assert low_score is not None
assert low_score.status == "rejected"
assert rejected is not None
assert rejected.status == "rejected"
def test_validation_result_rejects_unknown_status() -> None:
with pytest.raises(ValueError, match="unknown validation status"):
ValidationResult(status="pending", score=0.9, validator="test") # type: ignore[arg-type]
def test_validation_result_from_dict_rejects_unknown_explicit_status() -> None:
with pytest.raises(ValueError, match="unknown validation status"):
ValidationResult.from_dict({"status": "pending", "passed": True, "score": 0.9})
def test_validation_result_evidence_gaps_round_trip() -> None:
validation = ValidationResult(
status="insufficient_evidence",
score=0.4,
evidence_gaps=["missing command output", "missing file reference"],
validator="test",
)
restored = ValidationResult.from_dict(validation.to_dict())
assert restored is not None
assert restored.status == "insufficient_evidence"
assert restored.evidence_gaps == ["missing command output", "missing file reference"]
assert restored.to_dict()["evidence_gaps"] == ["missing command output", "missing file reference"]
def test_task_record_status_helpers_distinguish_review_and_failed() -> None:
needs_review = _task_record("needs_review")
failed = _task_record("failed")
assert needs_review.is_open is True
assert needs_review.is_execution_active is False
assert needs_review.requires_user_action is True
assert failed.is_open is False
assert failed.is_execution_active is False
assert failed.requires_user_action is False
def test_task_service_api_payload_emits_status_helpers(tmp_path: Path) -> None:
service = TaskService(tmp_path)
task = _task_record("needs_review")
payload = service.to_api_dict(task)
assert payload["is_open"] is True
assert payload["is_execution_active"] is False
assert payload["requires_user_action"] is True
def test_validation_failure_retries_once(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(
passed=False,
score=0.2,
issues=["missing tests"],
recommended_revision_prompt="Add tests before final response.",
validator="test",
),
ValidationResult(passed=True, score=0.88, validator="test"),
]
),
)
)
result = asyncio.run(
service.process_direct(
"implement and validate the task",
session_id="web:retry",
provider_bundle=_bundle("first draft", "revised draft"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(result.task_id)
assert result.output_text == "revised draft"
assert result.validation_result["accepted"] is True
assert task is not None
assert len(task.run_ids) == 2
visible_messages = loaded.session_manager.get_messages_as_conversation(result.session_id)
visible_contents = [message.get("content") for message in visible_messages]
assert "first draft" not in visible_contents
assert "revised draft" in visible_contents
def test_feedback_closes_or_abandons_internal_task(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[ValidationResult(passed=True, score=0.9, validator="test")]
),
task_execution_planner=StubTaskExecutionPlanner(),
)
)
result = asyncio.run(
service.process_direct(
"implement feedback handling",
session_id="web:feedback",
provider_bundle=_bundle("done"),
"write implementation plan",
session_id="web:acceptance",
provider_bundle=_bundle("Plan"),
)
)
loaded = service.create_loop().boot()
learning_calls = []
def build_learning_candidates_for_task(task_id: str, *, trigger_run_id: str) -> list[FakeLearningCandidate]:
learning_calls.append((task_id, trigger_run_id))
loaded = service.create_loop().boot()
generated: list[tuple[str, str]] = []
def build_learning_candidates_for_task(
task_id: str,
*,
final_accepted_run_id: str | None = None,
trigger_run_id: str | None = None,
) -> list[FakeLearningCandidate]:
generated.append((task_id, final_accepted_run_id or trigger_run_id or ""))
return [FakeLearningCandidate()]
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
feedback = asyncio.run(
service.submit_feedback(
session_id=result.session_id,
response = asyncio.run(
service.submit_acceptance(
session_id="web:acceptance",
run_id=result.run_id,
feedback_type="satisfied",
acceptance_type="accept",
)
)
assert feedback["task_status"] == "closed"
assert feedback["learning_candidates"] == [
assert response["task_status"] == "closed"
assert response["acceptance_type"] == "accept"
assert response["learning_candidates"] == [
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
]
assert learning_calls == [(result.task_id, result.run_id)]
assert generated == [(result.task_id, result.run_id)]
service2 = AgentService(
loader=EngineLoader(
workspace=tmp_path / "abandon",
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(passed=False, score=0.3, validator="test"),
ValidationResult(passed=False, score=0.3, validator="test"),
]
),
)
)
abandoned = asyncio.run(
service2.process_direct(
"implement another workflow",
session_id="web:abandon",
provider_bundle=_bundle("not enough", "still not enough"),
)
)
abandon_feedback = asyncio.run(
service2.submit_feedback(
session_id=abandoned.session_id,
run_id=abandoned.run_id,
feedback_type="abandon",
comment="too costly",
)
)
assert abandon_feedback["task_status"] == "abandoned"
assert abandon_feedback["learning_candidates"] == []
loaded2 = service2.create_loop().boot()
failure_events = [
event
for event in loaded2.session_manager.get_run_event_records(abandoned.session_id, abandoned.run_id)
if event.event_type == "task_failure_evidence_recorded"
]
assert len(failure_events) == 1
assert loaded2.memory_service.get_store().memory_entries == []
task_service = loaded.task_service
assert task_service is not None
task = task_service.get_task(result.task_id or "")
assert task is not None
assert task.metadata["final_accepted_run_id"] == result.run_id
def test_feedback_is_idempotent_and_projected_to_assistant_message(tmp_path: Path) -> None:
def test_revise_and_abandon_do_not_trigger_learning(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[ValidationResult(passed=True, score=0.9, validator="test")]
),
task_execution_planner=StubTaskExecutionPlanner(),
)
)
result = asyncio.run(
service.process_direct(
"implement feedback projection",
session_id="web:feedback-projection",
provider_bundle=_bundle("done"),
"summarize notes",
session_id="web:revise",
provider_bundle=_bundle("Summary"),
)
)
loaded = service.create_loop().boot()
first = asyncio.run(
service.submit_feedback(
session_id=result.session_id,
response = asyncio.run(
service.submit_acceptance(
session_id="web:revise",
run_id=result.run_id,
feedback_type="satisfied",
acceptance_type="revise",
comment="Add decisions",
)
)
second = asyncio.run(
assert response["task_status"] == "needs_revision"
assert response["learning_candidates"] == []
task_service = service.create_loop().boot().task_service
assert task_service is not None
task = task_service.get_task(result.task_id or "")
assert task is not None
assert task.feedback[0]["acceptance_type"] == "revise"
def test_legacy_feedback_endpoint_maps_satisfied_to_accept(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner(),
)
)
result = asyncio.run(
service.process_direct(
"prepare checklist",
session_id="web:legacy",
provider_bundle=_bundle("Checklist"),
)
)
response = asyncio.run(
service.submit_feedback(
session_id=result.session_id,
session_id="web:legacy",
run_id=result.run_id,
feedback_type="satisfied",
)
)
feedback_events = [
event
for event in loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
if event.event_type == "task_feedback_recorded"
]
assistant = [
message
for message in loaded.session_manager.get_messages_as_conversation(result.session_id)
if message.get("role") == "assistant" and message.get("run_id") == result.run_id
][-1]
assert first["task_status"] == "closed"
assert second["task_status"] == "closed"
assert len(feedback_events) == 1
assert assistant["feedback_state"] == "satisfied"
assert assistant["task_status"] == "closed"
assert assistant["validation_status"] == "passed"
with pytest.raises(ValueError, match="already recorded"):
asyncio.run(
service.submit_feedback(
session_id=result.session_id,
run_id=result.run_id,
feedback_type="abandon",
)
)
task = loaded.task_service.get_task(result.task_id)
assert task is not None
assert task.status == "closed"
assert response["acceptance_type"] == "accept"
assert response["feedback_type"] == "satisfied"
assert response["task_status"] == "closed"
def test_task_mode_team_plan_runs_subagent_then_main_synthesis(tmp_path: Path) -> None:
main_provider = StubProvider(
[
LLMResponse(content="final synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
]
)
sub_provider = StubProvider(
[
LLMResponse(content="sub-agent evidence", finish_reason="stop", provider_name="stub", model="stub-model")
]
)
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner([_team_plan()]),
validation_service=StubValidationService([ValidationResult(passed=True, score=0.9, validator="test")]),
)
)
def test_task_service_maps_legacy_status_and_feedback(tmp_path: Path) -> None:
service = TaskService(tmp_path)
task = service.create_task(session_id="s", description="legacy")
task.status = "awaiting_feedback"
task.feedback.append({"feedback_type": "satisfied", "run_id": "run-1"})
service.store.upsert_task(task)
result = asyncio.run(
service.process_direct(
"implement team-backed workflow",
session_id="web:team",
provider_bundle=_provider_bundle(main_provider),
team_provider_bundle_factory=lambda node: _provider_bundle(sub_provider),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(result.task_id)
events = loaded.session_manager.get_event_records(result.session_id)
loaded = service.get_task(task.task_id)
assert result.output_text == "final synthesized answer"
assert task is not None
assert len(task.run_ids) == 2
assert result.run_id == task.run_ids[-1]
assert any(event.event_type == "task_execution_planned" for event in events)
assert any(event.event_type == "task_team_run_completed" for event in events)
assert "sub-agent evidence" in main_provider.calls[0]["messages"][0]["content"]
assert "sub-agent evidence" != result.output_text
def test_task_mode_team_synthesis_runs_without_tools_and_receives_evidence(tmp_path: Path) -> None:
main_provider = StubProvider(
[
LLMResponse(content="final synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
]
)
sub_provider = StubProvider(
[
LLMResponse(content="sub-agent evidence", finish_reason="stop", provider_name="stub", model="stub-model")
]
)
validation = StubValidationService([ValidationResult(status="accepted", score=0.9, validator="test")])
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner([_team_plan()]),
validation_service=validation,
)
)
result = asyncio.run(
service.process_direct(
"implement team-backed workflow",
session_id="web:team-no-tools",
provider_bundle=_provider_bundle(main_provider),
team_provider_bundle_factory=lambda node: _provider_bundle(sub_provider),
)
)
assert result.output_text == "final synthesized answer"
assert main_provider.calls[0]["tools"] is None
assert "sub-agent evidence" in main_provider.calls[0]["messages"][0]["content"]
assert "Task evidence packet" in validation.calls[0]["evidence_text"]
def test_task_mode_team_failure_still_uses_main_synthesis(tmp_path: Path) -> None:
main_provider = StubProvider(
[
LLMResponse(content="fallback synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
]
)
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner([_team_plan()]),
validation_service=StubValidationService([ValidationResult(passed=True, score=0.9, validator="test")]),
)
)
result = asyncio.run(
service.process_direct(
"implement workflow despite team failure",
session_id="web:team-failure",
provider_bundle=_provider_bundle(main_provider),
team_provider_bundle_factory=lambda node: (_ for _ in ()).throw(RuntimeError("sub-agent unavailable")),
)
)
loaded = service.create_loop().boot()
events = loaded.session_manager.get_event_records(result.session_id)
assert result.output_text == "fallback synthesized answer"
assert any(event.event_type == "task_team_run_failed" for event in events)
assert "sub-agent unavailable" in main_provider.calls[0]["messages"][0]["content"]
assert "same class of tools fails repeatedly" in main_provider.calls[0]["messages"][0]["content"]
assert "user-visible fallback answer" in main_provider.calls[0]["messages"][0]["content"]
def test_insufficient_evidence_moves_task_to_needs_review(tmp_path: Path) -> None:
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=_single_planner(),
validation_service=StubValidationService(
[
ValidationResult(
status="insufficient_evidence",
score=0.4,
evidence_gaps=["source missing"],
validator="test",
)
]
),
)
)
result = asyncio.run(
service.process_direct(
"answer with uncertain evidence",
session_id="web:needs-review",
provider_bundle=_bundle("possible answer"),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(result.task_id)
events = loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
validation_event = next(event for event in events if event.event_type == "task_validation_snapshotted")
assert task is not None
assert task.status == "needs_review"
assert task.requires_user_action is True
assert task.is_execution_active is False
assert validation_event.event_payload["validation_result"]["status"] == "insufficient_evidence"
assert validation_event.event_payload["retry_scheduled"] is False
assert validation_event.event_payload["validation_debug"]["tool_result_count"] >= 0
def test_task_mode_team_retry_hides_first_synthesis_run(tmp_path: Path) -> None:
main_provider = StubProvider(
[
LLMResponse(content="first synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model"),
LLMResponse(content="revised synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model"),
]
)
sub_providers = [
StubProvider([LLMResponse(content="first evidence", finish_reason="stop", provider_name="stub", model="stub-model")]),
StubProvider([LLMResponse(content="second evidence", finish_reason="stop", provider_name="stub", model="stub-model")]),
]
service = AgentService(
loader=EngineLoader(
workspace=tmp_path,
task_execution_planner=StubTaskExecutionPlanner([_team_plan(), _team_plan()]),
validation_service=StubValidationService(
[
ValidationResult(passed=False, score=0.2, recommended_revision_prompt="revise", validator="test"),
ValidationResult(passed=True, score=0.9, validator="test"),
]
),
)
)
result = asyncio.run(
service.process_direct(
"implement and validate with team",
session_id="web:team-retry",
provider_bundle=_provider_bundle(main_provider),
team_provider_bundle_factory=lambda node: _provider_bundle(sub_providers.pop(0)),
)
)
loaded = service.create_loop().boot()
task = loaded.task_service.get_task(result.task_id)
visible = loaded.session_manager.get_messages_as_conversation(result.session_id)
visible_contents = [message.get("content") for message in visible]
run_records = {record.run_id: record for record in loaded.run_memory_store.list_runs()}
assert result.output_text == "revised synthesized answer"
assert task is not None
assert len(task.run_ids) == 4
assert "first synthesized answer" not in visible_contents
assert "revised synthesized answer" in visible_contents
for run_id in task.run_ids:
record = run_records[run_id]
events = loaded.session_manager.get_run_event_records(record.session_id, run_id)
skill_effects = [event for event in events if event.event_type == "skill_effects_snapshotted"]
assert skill_effects
assert skill_effects[-1].event_payload["candidate_generation_allowed"] is False
def test_context_builder_strips_ui_projection_fields_from_provider_history() -> None:
result = ContextBuilder().build_messages(
ContextBuildInput(
history=[
{
"role": "assistant",
"content": "done",
"run_id": "run-1",
"task_id": "task-1",
"task_status": "closed",
"validation_status": "passed",
"feedback_state": "satisfied",
}
],
)
)
assistant = result.messages[-1]
assert assistant == {"role": "assistant", "content": "done"}
def test_context_builder_normalizes_persisted_tool_arguments() -> None:
result = ContextBuilder().build_messages(
ContextBuildInput(
history=[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "cron",
"arguments": {"action": "add", "mode": "notification"},
},
}
],
}
],
)
)
tool_call = result.messages[-1]["tool_calls"][0]
assert tool_call["function"]["arguments"] == '{"action": "add", "mode": "notification"}'
def test_llm_validator_parse_failure_is_not_accepted(tmp_path: Path) -> None:
task_service = TaskService(tmp_path / "tasks")
task = task_service.create_task(session_id="web:validator", description="implement validator handling")
validation = asyncio.run(
ValidationService().validate_task_result(
task=task,
user_message="implement validator handling",
final_output="done",
provider_bundle=_main_only_bundle("not json"),
)
)
assert validation.accepted is False
assert validation.status == "validator_error"
assert validation.validator == "llm_error"
assert validation.issues
assert loaded is not None
assert loaded.status == "awaiting_acceptance"
assert loaded.feedback[0]["acceptance_type"] == "accept"

View File

@ -0,0 +1,141 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.telegram import TelegramAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeTelegramClient:
def __init__(self) -> None:
self.sent = []
async def send_message(self, **kwargs):
self.sent.append(kwargs)
def test_telegram_normalizes_private_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300, "username": "ivan"},
}
}
)
message = sink.messages[0]
assert message.channel == "telegram-main"
assert message.content == "hello"
assert message.session_id == "telegram-main:bot-main:200"
assert message.channel_identity.peer_type == "dm"
assert message.channel_identity.user_id == "300"
assert message.channel_identity.message_id == "100"
asyncio.run(run())
def test_telegram_group_requires_mention_when_configured() -> None:
async def run() -> None:
sink = FakeSink()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"requireMentionInGroups": True, "botUsername": "beaver_bot"},
client=FakeTelegramClient(),
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 101,
"text": "hello group",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 102,
"text": "@beaver_bot hello",
"chat": {"id": -20, "type": "group"},
"from": {"id": 300},
}
}
)
assert len(sink.messages) == 1
assert sink.messages[0].content == "hello"
asyncio.run(run())
def test_telegram_sends_chunked_reply_to_identity_target() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeTelegramClient()
adapter = TelegramAdapter(
channel_id="telegram-main",
kind="telegram",
mode="polling",
account_id="bot-main",
display_name=None,
inbound_sink=sink,
secrets={"botToken": "x"},
config={"maxMessageChars": 3},
client=client,
)
await adapter.handle_update_payload(
{
"message": {
"message_id": 100,
"text": "hello",
"chat": {"id": 200, "type": "private"},
"from": {"id": 300},
}
}
)
await adapter.send(
OutboundMessage(
channel="telegram-main",
content="abcdef",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
)
)
assert [item["text"] for item in client.sent] == ["abc", "def"]
assert client.sent[0]["chat_id"] == "200"
asyncio.run(run())

View File

@ -0,0 +1,143 @@
from __future__ import annotations
import asyncio
from beaver.interfaces.channels.connections import (
ChannelConnectionStore,
CredentialStore,
TelegramConnector,
)
class FakeTelegramClient:
async def get_me(self):
return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"}
class BrokenTelegramClient:
async def get_me(self):
raise RuntimeError("invalid token")
def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id="user-1",
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096},
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
result = await connector.validate(connection.connection_id)
updated = connection_store.get(connection.connection_id)
assert result.ok is True
assert result.status == "connected"
assert result.account_id == "telegram:12345"
assert updated.account_id == "telegram:12345"
assert updated.display_name == "Beaver (@beaver_bot)"
assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"]
asyncio.run(run())
def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True},
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
spec = await connector.materialize_runtime(connection.connection_id)
assert spec.channel_id == connection.channel_id
assert spec.kind == "telegram"
assert spec.mode == "polling"
assert spec.account_id == "telegram:12345"
assert spec.config["max_message_chars"] == 4096
assert spec.config["require_mention_in_groups"] is True
assert spec.secrets_ref == credentials_ref
asyncio.run(run())
def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"})
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="",
owner_user_id=None,
auth_type="token",
credentials_ref=credentials_ref,
)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: BrokenTelegramClient(),
)
result = await connector.validate(connection.connection_id)
assert result.ok is False
assert result.status == "error"
assert "invalid token" in (result.error or "")
asyncio.run(run())
def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None:
async def run() -> None:
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
credential_store = CredentialStore(tmp_path / "credentials.json")
connection = connection_store.create(
kind="telegram",
mode="polling",
display_name="Telegram Main",
account_id="telegram:12345",
owner_user_id=None,
auth_type="token",
)
connection_store.update_status(connection.connection_id, status="connected", last_error=None)
connector = TelegramConnector(
connection_store=connection_store,
credential_store=credential_store,
client_factory=lambda token: FakeTelegramClient(),
)
await connector.revoke(connection.connection_id)
assert connection_store.get(connection.connection_id).status == "connected"
asyncio.run(run())

View File

@ -0,0 +1,243 @@
import asyncio
import json
import time
from pathlib import Path
from fastapi.testclient import TestClient
from beaver.foundation.events import InboundMessage, OutboundMessage
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService
class TerminalFakeAgentService(AgentService):
def __init__(self, *, config_path: Path, delay_seconds: float = 0.0) -> None:
super().__init__(config_path=config_path)
self.delay_seconds = delay_seconds
self.inbound_calls: list[InboundMessage] = []
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
self.inbound_calls.append(inbound)
if self.delay_seconds:
await asyncio.sleep(self.delay_seconds)
return OutboundMessage(
message_id=inbound.message_id,
channel=inbound.channel,
content=f"echo:{inbound.content}",
session_id=inbound.session_id,
finish_reason="stop",
run_id="run-1",
channel_identity=inbound.channel_identity,
)
def write_terminal_config(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "openai/gpt-5"}},
"providers": {},
"channels": {
"terminal-dev": {
"enabled": True,
"kind": "terminal",
"mode": "websocket",
"accountId": "local",
"displayName": "Terminal Dev",
"config": {"heartbeatSeconds": 30, "maxMessageChars": 20000},
}
},
}
),
encoding="utf-8",
)
return config_path
def test_terminal_websocket_connect_ping_and_message_roundtrip(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json(
{
"type": "connect",
"peer_id": "device-001",
"device_name": "desk-terminal",
"capabilities": ["text"],
}
)
assert websocket.receive_json() == {
"type": "connected",
"channel_id": "terminal-dev",
"session_id": "terminal-dev:local:device-001",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
websocket.send_json(
{
"type": "message",
"message_id": "device-001-000001",
"text": "hello",
}
)
assert websocket.receive_json() == {
"type": "ack",
"message_id": "device-001-000001",
"session_id": "terminal-dev:local:device-001",
"accepted": True,
}
reply = websocket.receive_json()
service.close()
assert reply == {
"type": "message",
"role": "assistant",
"message_id": "device-001-000001",
"run_id": "run-1",
"text": "echo:hello",
"finish_reason": "stop",
}
assert len(service.inbound_calls) == 1
inbound = service.inbound_calls[0]
assert inbound.channel == "terminal-dev"
assert inbound.content == "hello"
assert inbound.content_type == "text"
assert inbound.session_id == "terminal-dev:local:device-001"
assert inbound.channel_identity is not None
assert inbound.channel_identity.peer_id == "device-001"
assert inbound.channel_identity.peer_type == "terminal"
assert inbound.channel_identity.message_id == "device-001-000001"
def test_terminal_websocket_rejects_message_before_connect(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "message", "message_id": "m1", "text": "hello"})
assert websocket.receive_json() == {
"type": "error",
"error": "connect is required before message",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_unknown_frame_keeps_connection_open(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "example"})
assert websocket.receive_json() == {
"type": "error",
"error": "Unsupported websocket frame type: example",
}
websocket.send_json({"type": "ping"})
assert websocket.receive_json() == {"type": "pong"}
service.close()
def test_terminal_websocket_validates_message_fields(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "text": "hello"})
assert websocket.receive_json() == {"type": "error", "error": "message_id is required"}
websocket.send_json({"type": "message", "message_id": "m1", "text": " "})
assert websocket.receive_json() == {"type": "error", "error": "text is required"}
service.close()
assert service.inbound_calls == []
def test_terminal_websocket_duplicate_message_returns_cached_reply(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
frame = {"type": "message", "message_id": "device-001-000001", "text": "hello"}
websocket.send_json(frame)
assert websocket.receive_json()["accepted"] is True
assert websocket.receive_json()["text"] == "echo:hello"
websocket.send_json(frame)
duplicate = websocket.receive_json()
service.close()
assert duplicate["type"] == "ack"
assert duplicate["accepted"] is False
assert duplicate["duplicate"] is True
assert duplicate["pending"] is False
assert duplicate["reply"] == "echo:hello"
assert len(service.inbound_calls) == 1
def test_terminal_websocket_disconnect_before_reply_records_unclaimed(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path, delay_seconds=0.05)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
websocket.send_json({"type": "message", "message_id": "device-001-000001", "text": "slow"})
assert websocket.receive_json()["accepted"] is True
time.sleep(0.15)
events = client.get("/api/channels/terminal-dev/events").json()
service.close()
kinds = [event["kind"] for event in events]
assert "terminal_disconnected" in kinds
assert "outbound_unclaimed" in kinds
def test_terminal_channel_status_exposes_websocket_url_and_peer_count(tmp_path: Path) -> None:
config_path = write_terminal_config(tmp_path)
service = TerminalFakeAgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
initial = client.get("/api/status").json()["channels"][0]
assert initial["channel_id"] == "terminal-dev"
assert initial["websocket_url"] == "/api/channels/terminal-dev/ws"
assert initial["connected_peers"] == 0
assert "persistent_connection" in initial["capabilities"]
with client.websocket_connect("/api/channels/terminal-dev/ws") as websocket:
websocket.send_json({"type": "connect", "peer_id": "device-001"})
assert websocket.receive_json()["type"] == "connected"
connected = client.get("/api/status").json()["channels"][0]
assert connected["connected_peers"] == 1
service.close()

View File

@ -0,0 +1,44 @@
from __future__ import annotations
import asyncio
from beaver.tools.builtins import web
class _FakeResponse:
headers = {"content-type": "text/html"}
status_code = 200
text = '<a class="result__a" href="https://example.com">Example</a>'
url = "https://example.com"
def raise_for_status(self) -> None:
return None
class _FakeAsyncClient:
calls: list[dict[str, object]] = []
def __init__(self, **kwargs: object) -> None:
self.calls.append(kwargs)
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
return _FakeResponse()
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
_FakeAsyncClient.calls = []
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
async def _run() -> None:
await web.WebFetchTool().execute(url="https://example.com")
await web.WebSearchTool().execute(query="example")
asyncio.run(_run())
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]

View File

@ -20,8 +20,8 @@ class StubRunResult:
model: str | None = "stub-model"
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
task_id: str | None = "task-1"
task_status: str | None = "awaiting_feedback"
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
task_status: str | None = "awaiting_acceptance"
validation_result: dict[str, Any] | None = None
class StubAgentService(AgentService):
@ -101,9 +101,10 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
assert message["session_id"] == "web:alpha"
assert message["run_id"] == "run-1"
assert message["task_id"] == "task-1"
assert message["task_status"] == "awaiting_feedback"
assert message["validation_result"] == {"accepted": True}
assert message["validation_status"] == "passed"
assert message["task_status"] == "awaiting_acceptance"
assert message["evidence_status"] == "recorded"
assert message["validation_result"] is None
assert "validation_status" not in message
assert message["metadata"]["input_metadata"] == {
"source": "test",
"attachments": [{"file_id": "file-1", "name": "a.txt"}],

View File

@ -0,0 +1,129 @@
import asyncio
from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.platforms.weixin import WeixinAdapter
class FakeSink:
def __init__(self) -> None:
self.messages = []
async def accept_inbound(self, message):
self.messages.append(message)
class FakeWeixinClient:
def __init__(self) -> None:
self.sent = []
async def send_text(self, *, peer_id: str, text: str, context_token: str | None):
self.sent.append({"peer_id": peer_id, "text": text, "context_token": context_token})
def test_weixin_normalizes_direct_text_message() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"room_id": "",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
message = sink.messages[0]
assert message.content == "hello"
assert message.session_id == "weixin-main:wx-main:wx_user"
assert message.channel_identity.peer_type == "dm"
assert message.metadata["context_token"] == "ctx1"
asyncio.run(run())
def test_weixin_group_message_is_best_effort() -> None:
async def run() -> None:
sink = FakeSink()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={"groupPolicy": "open"},
client=FakeWeixinClient(),
)
await adapter.handle_message_payload(
{
"id": "m2",
"from": "wx_user",
"room_id": "room1",
"type": "text",
"text": "hello room",
"context_token": "ctx2",
}
)
message = sink.messages[0]
assert message.session_id == "weixin-main:wx-main:room1"
assert message.channel_identity.peer_type == "group"
assert message.channel_identity.user_id == "wx_user"
asyncio.run(run())
def test_weixin_sends_text_with_context_token() -> None:
async def run() -> None:
sink = FakeSink()
client = FakeWeixinClient()
adapter = WeixinAdapter(
channel_id="weixin-main",
kind="weixin",
mode="polling",
account_id="wx-main",
display_name=None,
inbound_sink=sink,
secrets={"token": "token"},
config={},
client=client,
)
await adapter.handle_message_payload(
{
"id": "m1",
"from": "wx_user",
"type": "text",
"text": "hello",
"context_token": "ctx1",
}
)
await adapter.send(
OutboundMessage(
channel="weixin-main",
content="ok",
session_id=sink.messages[0].session_id,
finish_reason="stop",
channel_identity=sink.messages[0].channel_identity,
metadata={"inbound_metadata": sink.messages[0].metadata},
)
)
assert client.sent == [{"peer_id": "wx_user", "text": "ok", "context_token": "ctx1"}]
asyncio.run(run())

View File

@ -252,27 +252,51 @@ dependencies = [
]
[package.optional-dependencies]
channels = [
{ name = "aiohttp" },
{ name = "lark-oapi" },
{ name = "python-telegram-bot" },
]
dev = [
{ name = "pytest" },
]
feishu = [
{ name = "lark-oapi" },
]
qqbot = [
{ name = "aiohttp" },
]
telegram = [
{ name = "python-telegram-bot" },
]
weixin = [
{ name = "aiohttp" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", marker = "extra == 'channels'", specifier = ">=3.9.0,<4.0.0" },
{ name = "aiohttp", marker = "extra == 'qqbot'", specifier = ">=3.9.0,<4.0.0" },
{ name = "aiohttp", marker = "extra == 'weixin'", specifier = ">=3.9.0,<4.0.0" },
{ name = "anthropic", specifier = ">=0.51.0,<1.0.0" },
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "json-repair", specifier = ">=0.39.0,<1.0.0" },
{ name = "lark-oapi", marker = "extra == 'channels'", specifier = ">=1.4.22,<2.0.0" },
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.4.22,<2.0.0" },
{ name = "litellm", specifier = ">=1.79.0,<2.0.0" },
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
{ name = "python-telegram-bot", marker = "extra == 'channels'", specifier = ">=22.0,<23.0" },
{ name = "python-telegram-bot", marker = "extra == 'telegram'", specifier = ">=22.0,<23.0" },
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "telegram", "feishu", "qqbot", "weixin", "channels"]
[[package]]
name = "cachetools"
@ -1277,6 +1301,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "lark-oapi"
version = "1.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "websockets" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/54/a3b649b83299606aa7ebfd2391663fde650e934421dfba37af171bfbf456/lark_oapi-1.6.7-py3-none-any.whl", hash = "sha256:df1d44891d266f5c063daa1d37ae6f72c7f166bdc2fb01e607088410e952b92c", size = 7146261, upload-time = "2026-05-28T03:32:21.268Z" },
]
[[package]]
name = "litellm"
version = "1.80.0"
@ -1759,6 +1798,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "pydantic"
version = "2.13.3"
@ -1973,6 +2042,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
name = "python-telegram-bot"
version = "22.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpcore", marker = "python_full_version >= '3.14'" },
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/25/2258161b1069e66d6c39c0a602dbe57461d4767dc0012539970ea40bc9d6/python_telegram_bot-22.7.tar.gz", hash = "sha256:784b59ea3852fe4616ad63b4a0264c755637f5d725e87755ecdee28300febf61", size = 1516454, upload-time = "2026-03-16T09:36:03.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/f7/0e2f89dd62f45d46d4ea0d8aec5893ce5b37389638db010c117f46f11450/python_telegram_bot-22.7-py3-none-any.whl", hash = "sha256:d72eed532cf763758cd9331b57a6d790aff0bb4d37d8f4e92149436fe21c6475", size = 745365, upload-time = "2026-03-16T09:36:01.498Z" },
]
[[package]]
name = "pywin32"
version = "311"
@ -2189,6 +2271,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
@ -2687,61 +2781,44 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]

View File

@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
NETWORK_NAME="${NETWORK_NAME:-}"
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
SEED_INITIAL_SKILLS=1
FORCE_BUILD=0
REPLACE=0
@ -78,6 +80,9 @@ Optional:
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
--network <name> Optional docker network name.
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
Default: ../skills
--skip-initial-skills Do not seed initial workspace skills.
--build Force rebuild image before running.
--replace Remove existing container with same name before running.
--help Show this help.
@ -225,6 +230,69 @@ data = {
"name": os.environ["BACKEND_NAME"].strip(),
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {
"botToken": "",
},
"config": {
"requireMentionInGroups": True,
"maxMessageChars": 4096,
},
},
"feishu-main": {
"enabled": False,
"kind": "feishu",
"mode": "websocket",
"accountId": "tenant-main",
"displayName": "Feishu Main",
"secrets": {
"appId": "",
"appSecret": "",
},
"config": {
"domain": "feishu",
"connectionMode": "websocket",
"requireMentionInGroups": True,
},
},
"qqbot-main": {
"enabled": False,
"kind": "qqbot",
"mode": "websocket",
"accountId": "qqbot-main",
"displayName": "QQ Bot Main",
"secrets": {
"appId": "",
"clientSecret": "",
},
"config": {
"dmPolicy": "open",
"groupPolicy": "allowlist",
"markdownSupport": False,
},
},
"weixin-main": {
"enabled": False,
"kind": "weixin",
"mode": "polling",
"accountId": "wx-main",
"displayName": "Weixin Main",
"secrets": {
"token": "",
},
"config": {
"dmPolicy": "open",
"groupPolicy": "disabled",
"textBatchDelaySeconds": 0.5,
},
},
},
}
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@ -255,6 +323,66 @@ target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encodin
PY
}
seed_initial_skills() {
local workspace_path="$1"
local initial_skills_dir="$2"
local target_dir="${workspace_path}/skills"
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
return
fi
if [[ ! -d "$initial_skills_dir" ]]; then
log "initial skills directory not found, skipping: ${initial_skills_dir}"
return
fi
mkdir -p "$target_dir"
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
import json
import shutil
import os
from pathlib import Path
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
target = Path(os.environ["TARGET_DIR"]).resolve()
for child in sorted(initial.iterdir()):
if child.name.startswith("."):
continue
destination = target / child.name
if destination.exists():
continue
if child.is_dir():
shutil.copytree(child, destination)
elif child.is_file():
shutil.copy2(child, destination)
for index_name in ("published", "disabled"):
initial_index = initial / "_index" / f"{index_name}.json"
target_index = target / "_index" / f"{index_name}.json"
if not initial_index.exists():
continue
try:
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
initial_items = []
if target_index.exists():
try:
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
except json.JSONDecodeError:
target_items = []
else:
target_items = []
merged = []
for item in [*target_items, *initial_items]:
text = str(item).strip()
if text and text not in merged:
merged.append(text)
target_index.parent.mkdir(parents=True, exist_ok=True)
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
PY
}
render_runtime_env_file() {
local target_path="$1"
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
HOST_BIND_IP="${2:-}"
shift 2
;;
--initial-skills-dir)
INITIAL_SKILLS_DIR="${2:-}"
shift 2
;;
--skip-initial-skills)
SEED_INITIAL_SKILLS=0
shift
;;
--build)
FORCE_BUILD=1
shift
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
render_config_json "$CONFIG_PATH"
render_auth_users_json "$AUTH_USERS_PATH"
render_runtime_env_file "$RUNTIME_ENV_PATH"
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
log "building image ${IMAGE_NAME}"
@ -564,6 +701,7 @@ RUN_ARGS=(
-e "APP_PUBLIC_PORT=8080"
-e "APP_FRONTEND_PORT=3000"
-e "APP_BACKEND_PORT=18080"
-e "BEAVER_ENABLE_SELF_RESTART=1"
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
--label "beaver.instance.id=${INSTANCE_ID}"
--label "beaver.instance.slug=${INSTANCE_SLUG}"

View File

@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { containedJsonTextClass } from '@/lib/text-wrapping';
function eventLabel(event: ChatLogEvent): string {
return event.event_type || event.role || 'event';
@ -175,7 +176,7 @@ export default function LogsPage() {
return (
<div
key={`${event.message_id ?? index}:${event.event_type}`}
className="rounded-lg border border-border bg-background"
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
@ -188,7 +189,7 @@ export default function LogsPage() {
</div>
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
</div>
<pre className="max-h-[520px] overflow-auto whitespace-pre-wrap break-words p-3 text-xs leading-5 text-foreground">
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
{body || formatPayload(event)}
</pre>
</div>

View File

@ -19,7 +19,12 @@ import {
uploadFile,
wsManager,
} from '@/lib/api';
import { mergeServerWithPendingUsers, shouldMergePendingUsers } from '@/lib/chat-messages';
import {
getSessionRefreshIntervalMs,
mergeServerWithPendingUsers,
shouldDisplayChatMessage,
shouldMergePendingUsers,
} from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress';
@ -32,7 +37,7 @@ function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
if (status === 'awaiting_feedback') return pickAppText(locale, '待反馈', 'Awaiting feedback');
if (status === 'awaiting_acceptance') return pickAppText(locale, '待验收', 'Awaiting acceptance');
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
return pickAppText(locale, '进行中', 'Active');
}
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
return stored == null ? false : stored !== 'false';
}
function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
@ -78,6 +87,7 @@ export default function ChatPage() {
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -157,10 +167,11 @@ export default function ChatPage() {
setSessionProcess(key, process);
}
void loadActiveTask(key);
const shouldMergePending = shouldMergePendingUsers(detail.messages, localSnapshot, waitingForReply);
const displayMessages = detail.messages.filter(shouldDisplayChatMessage);
const shouldMergePending = shouldMergePendingUsers(displayMessages, localSnapshot, waitingForReply);
const nextMessages = shouldMergePending
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
? mergeServerWithPendingUsers(displayMessages, localSnapshot)
: displayMessages;
setMessages(nextMessages);
shouldSnapToLatestRef.current = true;
const last = nextMessages[nextMessages.length - 1];
@ -217,15 +228,11 @@ export default function ChatPage() {
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
const validationResult = data.validation_result ?? data.metadata?.validation_result;
const validationStatus = data.validation_status
? data.validation_status
: validationResult
? ((validationResult as Record<string, unknown>).accepted === true ? 'passed' : 'failed')
: 'unknown';
setIsThinking(false);
setIsLoading(false);
addMessage({
const rawEvidenceStatus = data.evidence_status ?? data.metadata?.evidence_status;
const evidenceStatus = rawEvidenceStatus === 'recorded' ? 'recorded' : undefined;
const assistantMessage = {
role: 'assistant',
content: typeof data.content === 'string' ? data.content : '',
timestamp: new Date().toISOString(),
@ -233,8 +240,11 @@ export default function ChatPage() {
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
task_id: data.task_id ?? data.metadata?.task_id ?? null,
task_status: data.task_status ?? data.metadata?.task_status ?? null,
validation_status: validationStatus,
});
evidence_status: evidenceStatus,
} as const;
if (shouldDisplayChatMessage(assistantMessage)) {
addMessage(assistantMessage);
}
void loadSessionMessages(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
loadSessions();
@ -247,14 +257,26 @@ export default function ChatPage() {
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => {
if (!isLoading && !isThinking) {
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
if (intervalMs == null) {
return;
}
const timer = setInterval(() => {
loadSessionMessages(useChatStore.getState().sessionId);
}, 1500);
const currentSessionId = useChatStore.getState().sessionId;
void loadSessionMessages(currentSessionId);
void loadSessions();
}, intervalMs);
return () => clearInterval(timer);
}, [isLoading, isThinking, loadSessionMessages]);
}, [documentHidden, isLoading, isThinking, loadSessionMessages, loadSessions]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const updateVisibility = () => setDocumentHidden(isDocumentHidden());
document.addEventListener('visibilitychange', updateVisibility);
return () => document.removeEventListener('visibilitychange', updateVisibility);
}, []);
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
@ -359,17 +381,18 @@ export default function ChatPage() {
await loadSessions();
return;
}
addMessage({
const assistantMessage = {
role: 'assistant',
content: result.response,
timestamp: new Date().toISOString(),
run_id: result.run_id,
task_id: result.task_id,
task_status: result.task_status,
validation_status: result.validation_result
? (result.validation_result.accepted === true ? 'passed' : 'failed')
: 'unknown',
});
evidence_status: result.evidence_status === 'recorded' ? 'recorded' : undefined,
} as const;
if (shouldDisplayChatMessage(assistantMessage)) {
addMessage(assistantMessage);
}
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
void loadActiveTask(sessionId);
loadSessions();
@ -393,7 +416,7 @@ export default function ChatPage() {
}
}, [addMessage, clearInputDraft, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
const handleFeedback = useCallback(async (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => {
updateMessageFeedback(runId, feedbackType);
try {
await submitChatFeedback({

View File

@ -73,6 +73,7 @@ import type {
} from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
@ -1094,7 +1095,7 @@ function ReadableFact({
{icon}
{label}
</div>
<div className="break-words text-sm leading-5">{value || '-'}</div>
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
</div>
);
}
@ -1119,12 +1120,12 @@ function MetricTile({
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return (
<details className="mt-3 rounded-md border border-border bg-white">
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title}
<ChevronDown className="h-3.5 w-3.5" />
</summary>
<pre className="max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5">
<pre className={`max-h-72 overflow-auto border-t border-border p-3 text-xs leading-5 ${containedJsonTextClass}`}>
{JSON.stringify(payload, null, 2)}
</pre>
</details>
@ -1238,7 +1239,7 @@ function riskLabel(risk: string, t: (zh: string, en: string) => string): string
function triggerReasonLabel(reason: string, t: (zh: string, en: string) => string): string {
const labels: Record<string, string> = {
validation_accepted_and_user_satisfied: t('任务验证通过且用户满意', 'Validation accepted and user satisfied'),
task_accepted: t('任务已接受', 'Task accepted'),
};
return labels[reason] || reason;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -142,7 +142,7 @@ function OrdinaryTasks() {
</div>
</TableCell>
<TableCell>
<Badge variant={task.status === 'awaiting_feedback' || task.status === 'closed' ? 'default' : 'secondary'}>
<Badge variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</TableCell>
@ -185,8 +185,7 @@ function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
validating: ['验证中', 'Validating'],
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],

View File

@ -88,6 +88,32 @@
}
}
@layer utilities {
.contained-long-text {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-preserved-long-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.contained-json-text {
min-width: 0;
max-width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
}
/* Override Tailwind Typography table defaults for markdown rendering */
.prose table {
margin-top: 0;

View File

@ -27,7 +27,7 @@ export function ChatWorkbench({
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void;
}) {
return (

View File

@ -3,9 +3,11 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { containedLongTextClass } from '@/lib/text-wrapping';
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-w-none text-[#1D1715] prose-headings:text-[#0B0B0B] prose-p:text-[#1D1715] prose-p:leading-7 prose-strong:text-[#0B0B0B] prose-a:text-[#342E2B] prose-a:underline prose-a:decoration-[#B8AEA8] prose-a:underline-offset-4 prose-li:text-[#1D1715] prose-blockquote:border-l-[#D8D2CE] prose-blockquote:text-[#4F4642] prose-code:rounded-md prose-code:bg-[#ECE8E5] prose-code:px-1.5 prose-code:py-0.5 prose-code:text-[#342E2B] prose-code:[overflow-wrap:anywhere] prose-pre:border prose-pre:border-[#D8D2CE] prose-pre:bg-[#ECE8E5] prose-pre:text-[#342E2B] prose-pre:whitespace-pre-wrap prose-pre:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${containedLongTextClass}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{

View File

@ -6,12 +6,13 @@ import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, Thumbs
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { getTaskCardMessageIndexes } from '@/lib/chat-messages';
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
@ -49,19 +50,14 @@ function MessageBubble({
message: ChatMessage;
showTaskCard: boolean;
canSendFeedback: boolean;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void;
}) {
const { locale } = useAppI18n();
const isUser = message.role === 'user';
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | null>(null);
const textContent = normalizedMessageText(message.content);
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
const [feedbackComment, setFeedbackComment] = React.useState('');
const validationFailed = message.validation_status === 'failed';
const validationDetails =
validationFailed
? pickAppText(locale, '详细原因会在任务验证区展示;展开任务可查看验证报告。', 'Detailed reasons are shown in the task validation area. Open the task to inspect the validation report.')
: '';
return (
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
@ -71,7 +67,7 @@ function MessageBubble({
</div>
)}
<div
className={`max-w-[88%] px-4 py-3 ${
className={`min-w-0 max-w-[88%] px-4 py-3 ${
isUser
? 'rounded-[28px] bg-primary text-primary-foreground'
: 'rounded-none bg-transparent text-[#1D1715]'
@ -97,14 +93,14 @@ function MessageBubble({
key={att.file_id}
href={fileUrl}
download={att.name}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
className={`flex min-w-0 items-center gap-2 px-3 py-2 rounded-md text-sm ${
isUser
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
: 'bg-muted hover:bg-muted/80'
}`}
>
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{att.name}</span>
<span className="min-w-0 truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-70 flex-shrink-0">
{att.size > 1024 * 1024
@ -119,7 +115,7 @@ function MessageBubble({
)}
{isUser ? (
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
) : (
<MarkdownContent content={textContent} />
)}
@ -142,22 +138,14 @@ function MessageBubble({
</div>
</div>
)}
{!isUser && validationFailed && (
<details className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
<summary className="cursor-pointer text-base font-semibold text-destructive">
{pickAppText(locale, '验证失败', 'Validation failed')}
</summary>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{validationDetails}</p>
</details>
)}
{!isUser && (canSendFeedback || message.feedback_state) && message.run_id && (
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
{message.feedback_state ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5" />
<span>
{message.feedback_state === 'satisfied'
? pickAppText(locale, '已标记满意', 'Marked satisfied')
{message.feedback_state === 'accept' || message.feedback_state === 'satisfied'
? pickAppText(locale, '已接受', 'Accepted')
: message.feedback_state === 'revise'
? pickAppText(locale, '已请求修改', 'Revision requested')
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
@ -168,11 +156,11 @@ function MessageBubble({
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setFeedbackMode('satisfied')}
onClick={() => setFeedbackMode('accept')}
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ThumbsUp className="h-3.5 w-3.5" />
{pickAppText(locale, '满意', 'Satisfied')}
{pickAppText(locale, '接受', 'Accept')}
</button>
<button
type="button"
@ -222,13 +210,6 @@ function MessageBubble({
)}
</>
)}
{message.validation_status && message.validation_status !== 'unknown' && (
<span className="text-xs text-muted-foreground">
{message.validation_status === 'passed'
? pickAppText(locale, '验证通过', 'Validated')
: pickAppText(locale, '验证未通过', 'Validation failed')}
</span>
)}
{message.feedback_error && (
<span className="text-xs text-destructive">{message.feedback_error}</span>
)}
@ -264,6 +245,17 @@ function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
);
}
function hasRenderableMessageContent(message: ChatMessage): boolean {
return hasVisibleChatContent(message);
}
function shouldHideMessage(message: ChatMessage): boolean {
if (shouldHideSystemAgentMessage(message)) {
return true;
}
return !shouldDisplayChatMessage(message);
}
function parseTimelineTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
@ -342,12 +334,12 @@ export function MessageList({
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void;
}) {
const { locale } = useAppI18n();
const visibleMessages = React.useMemo(
() => messages.filter((message) => !shouldHideSystemAgentMessage(message)),
() => messages.filter((message) => !shouldHideMessage(message)),
[messages]
);
const teamGroups = React.useMemo(
@ -385,14 +377,21 @@ export function MessageList({
() => getTaskCardMessageIndexes(visibleMessages),
[visibleMessages]
);
const latestFeedbackRunId = [...visibleMessages]
.reverse()
.find((message) =>
message.role === 'assistant'
&& message.run_id
&& message.task_id
&& message.task_status === 'awaiting_feedback'
)?.run_id;
const latestFeedbackMessageIndex = (() => {
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
const message = visibleMessages[index];
if (
message.role === 'assistant'
&& message.run_id
&& message.task_id
&& message.task_status === 'awaiting_acceptance'
&& hasRenderableMessageContent(message)
) {
return index;
}
}
return -1;
})();
return (
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
@ -411,7 +410,7 @@ export function MessageList({
key={item.key}
message={item.message}
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
canSendFeedback={Boolean(latestFeedbackRunId && item.message.run_id === latestFeedbackRunId)}
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
onFeedback={onFeedback}
onRequestRevision={onRequestRevision}
/>

View File

@ -0,0 +1,242 @@
'use client';
import * as React from 'react';
import { CheckCircle2, Loader2, RefreshCw, ThumbsUp, XCircle } from 'lucide-react';
import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
export type TaskFeedbackType = 'accept' | 'revise' | 'abandon';
export type TaskFeedbackItem = {
acceptance_type?: unknown;
feedback_type?: unknown;
comment?: unknown;
created_at?: unknown;
run_id?: unknown;
};
type Props = {
sessionId: string;
runId: string | null;
taskStatus: string;
feedbackItems: TaskFeedbackItem[];
actionBusy: string | null;
revision?: string;
onRevisionChange?: (value: string) => void;
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
const READY_FOR_ACCEPTANCE_STATUSES = new Set<string>(['awaiting_acceptance', 'needs_revision']);
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
if (!runId) return null;
return [...items].reverse().find((item) => String(item.run_id || '') === runId) ?? null;
}
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
return [...items].reverse()[0] ?? null;
}
function feedbackKind(item: TaskFeedbackItem): string {
return String(item.acceptance_type || item.feedback_type || '');
}
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
return type || pickAppText(locale, '验收', 'Acceptance');
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
accept: ['接受', 'Accepted'],
satisfied: ['接受', 'Accepted'],
revise: ['请求修改', 'Revision requested'],
abandon: ['放弃任务', 'Abandoned'],
};
const label = labels[status];
return label ? pickAppText(locale, label[0], label[1]) : status;
}
function FeedbackButton({
type,
icon,
label,
actionBusy,
disabled,
onClick,
}: {
type: TaskFeedbackType;
icon: React.ReactNode;
label: string;
actionBusy: string | null;
disabled: boolean;
onClick: () => void;
}) {
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
return (
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
{label}
</Button>
);
}
export function TaskAcceptanceCard({
sessionId,
runId,
taskStatus,
feedbackItems,
actionBusy,
revision,
onRevisionChange,
onSubmit,
}: Props) {
const { locale } = useAppI18n();
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
{isRuntimeStatus(taskStatus) ? (
<TaskRuntimeStatusBadge status={taskStatus} />
) : (
<Badge variant="outline" className="text-[11px]">
{humanTaskStatus(taskStatus, locale)}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
<TaskAcceptanceControls
sessionId={sessionId}
runId={runId}
taskStatus={taskStatus}
feedbackItems={feedbackItems}
actionBusy={actionBusy}
revision={revision}
onRevisionChange={onRevisionChange}
onSubmit={onSubmit}
/>
</CardContent>
</Card>
);
}
export function TaskAcceptanceControls({
sessionId,
runId,
taskStatus,
feedbackItems,
actionBusy,
revision,
onRevisionChange,
onSubmit,
}: Props) {
const { locale } = useAppI18n();
const [localComment, setLocalComment] = React.useState('');
const comment = revision ?? localComment;
const setComment = onRevisionChange ?? setLocalComment;
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
const trimmedComment = comment.trim();
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
if (!runId || !canSubmit) return;
void onSubmit(feedbackType, nextComment);
};
return (
<div className="space-y-4">
{recordedFeedback ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div className="flex items-center gap-2 font-medium">
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(feedbackKind(recordedFeedback), locale)}
</div>
{recordedFeedback.comment ? <p className={`mt-2 text-muted-foreground ${containedPreservedLongTextClass}`}>{String(recordedFeedback.comment)}</p> : null}
{recordedFeedback.created_at ? (
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
) : null}
</div>
) : isFinalized ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
</div>
) : !isReadyForAcceptance ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '任务还在执行,完成后才能验收。', 'The task is still running. Acceptance becomes available when a result is ready.')}
</div>
) : !runId ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-3">
<FeedbackButton
type="accept"
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '接受', 'Accept')}
actionBusy={actionBusy}
disabled={!canSubmit}
onClick={() => submit('accept', trimmedComment || undefined)}
/>
<FeedbackButton
type="revise"
icon={<RefreshCw className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '需要修改', 'Needs revision')}
actionBusy={actionBusy}
disabled={!canSubmit || !trimmedComment}
onClick={() => submit('revise', trimmedComment)}
/>
<FeedbackButton
type="abandon"
icon={<XCircle className="mr-2 h-4 w-4" />}
label={pickAppText(locale, '放弃', 'Abandon')}
actionBusy={actionBusy}
disabled={!canSubmit}
onClick={() => submit('abandon', trimmedComment || undefined)}
/>
</div>
<Textarea
value={comment}
onChange={(event) => setComment(event.target.value)}
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
/>
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
<span className="font-mono">{runId || '-'}</span>
<span className="mx-1">·</span>
{pickAppText(locale, '会话:', 'Session: ')}
<span className="font-mono">{sessionId}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import Link from 'next/link';
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import type { BackendTask } from '@/types';
type Props = {
task: BackendTask;
activeLabel: string;
durationMs: number | null;
reviewTargetId?: string;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
function isRuntimeStatus(status: string): status is TaskRuntimeStatus {
return RUNTIME_STATUSES.has(status);
}
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
const map: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
running: ['执行中', 'Running'],
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
};
const item = map[status];
return item ? pickAppText(locale, item[0], item[1]) : status;
}
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
const { locale } = useAppI18n();
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务', 'Back to tasks')}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '对话', 'Chat')}
</Link>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
{isRuntimeStatus(task.status) ? (
<TaskRuntimeStatusBadge status={task.status} />
) : (
<Badge variant="outline" className="text-[11px]">
{humanTaskStatus(task.status, locale)}
</Badge>
)}
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
{showReviewLink ? (
<Button asChild variant="default" size="sm">
<a href={`#${reviewTargetId}`}>
<CheckCircle2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '验收', 'Review')}
</a>
</Button>
) : null}
</div>
</div>
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
{task.description && task.description !== title ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
</span>
<span>
{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(durationMs, locale)}
</span>
</div>
</div>
</div>
</header>
);
}

Some files were not shown because too many files have changed in this diff Show More