Compare commits
27 Commits
030bce8a60
...
channel-ru
| Author | SHA1 | Date | |
|---|---|---|---|
| c3d84b904a | |||
| ee972441f5 | |||
| d335199a64 | |||
| feeaccc0e3 | |||
| cf35edb4ca | |||
| e0a4862af8 | |||
| c3a0aef104 | |||
| b25713a141 | |||
| d74a1c9c12 | |||
| 834d4e1e2f | |||
| 6a6ddc21c0 | |||
| 826db8ec2e | |||
| 33a9845566 | |||
| 55b39563a0 | |||
| 41ac87e322 | |||
| 542b23ef6e | |||
| 9002d1206f | |||
| dd9f40b38c | |||
| 96562877cc | |||
| f58a57e5b8 | |||
| 362aae9b12 | |||
| 29d175222d | |||
| 2e4f8541ee | |||
| a1164dc49a | |||
| 7b638b083a | |||
| 6e9e74d1ee | |||
| 16347caf5e |
16
.env.example
16
.env.example
@ -28,3 +28,19 @@ BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
|||||||
|
|
||||||
# Must be reachable from auth-portal and authz-service containers.
|
# Must be reachable from auth-portal and authz-service containers.
|
||||||
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
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
1
.gitignore
vendored
@ -21,6 +21,7 @@ sessions/
|
|||||||
**/.ruff_cache/
|
**/.ruff_cache/
|
||||||
**/.mypy_cache/
|
**/.mypy_cache/
|
||||||
**/.cache/
|
**/.cache/
|
||||||
|
**/.codegraph/
|
||||||
**/.venv/
|
**/.venv/
|
||||||
**/dist/
|
**/dist/
|
||||||
**/build/
|
**/build/
|
||||||
|
|||||||
458
2026-06-01-hermes-gateway-llm-design.md
Normal file
458
2026-06-01-hermes-gateway-llm-design.md
Normal 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.
|
||||||
@ -47,8 +47,12 @@ ARG NPM_REGISTRY="https://registry.npmmirror.com"
|
|||||||
ARG NPM_FETCH_RETRIES="5"
|
ARG NPM_FETCH_RETRIES="5"
|
||||||
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
ARG NPM_FETCH_RETRY_MIN_TIMEOUT="20000"
|
||||||
ARG NPM_FETCH_RETRY_MAX_TIMEOUT="120000"
|
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 && \
|
apt-get install -y --no-install-recommends curl ca-certificates gnupg git nginx dumb-init && \
|
||||||
mkdir -p /etc/apt/keyrings && \
|
mkdir -p /etc/apt/keyrings && \
|
||||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
|
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/pyproject.toml backend/README.md ./
|
||||||
COPY backend/beaver/ ./beaver/
|
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
|
WORKDIR /opt/app/frontend
|
||||||
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
COPY --from=frontend-builder /build/frontend/next.config.js ./
|
||||||
|
|||||||
145
app-instance/backend/agents/registry.json
Normal file
145
app-instance/backend/agents/registry.json
Normal 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
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ from .builder import (
|
|||||||
ContextBuildInput,
|
ContextBuildInput,
|
||||||
ContextBuildResult,
|
ContextBuildResult,
|
||||||
ContextBuilder,
|
ContextBuilder,
|
||||||
|
RuntimeContext,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
SkillContext,
|
SkillContext,
|
||||||
)
|
)
|
||||||
@ -12,6 +13,7 @@ __all__ = [
|
|||||||
"ContextBuildInput",
|
"ContextBuildInput",
|
||||||
"ContextBuildResult",
|
"ContextBuildResult",
|
||||||
"ContextBuilder",
|
"ContextBuilder",
|
||||||
|
"RuntimeContext",
|
||||||
"SessionContext",
|
"SessionContext",
|
||||||
"SkillContext",
|
"SkillContext",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -76,10 +76,25 @@ class SessionContext:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
channel: 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
|
chat_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
parent_session_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)
|
@dataclass(slots=True)
|
||||||
class ContextBuildInput:
|
class ContextBuildInput:
|
||||||
"""一次上下文构建所需的全部输入。
|
"""一次上下文构建所需的全部输入。
|
||||||
@ -103,6 +118,7 @@ class ContextBuildInput:
|
|||||||
memory_snapshot: MemorySnapshot | None = None
|
memory_snapshot: MemorySnapshot | None = None
|
||||||
activated_skills: list[SkillContext] = field(default_factory=list)
|
activated_skills: list[SkillContext] = field(default_factory=list)
|
||||||
session_context: SessionContext | None = None
|
session_context: SessionContext | None = None
|
||||||
|
runtime_context: RuntimeContext | None = None
|
||||||
execution_context: str | None = None
|
execution_context: str | None = None
|
||||||
extra_sections: list[str] = field(default_factory=list)
|
extra_sections: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
@ -143,9 +159,10 @@ class ContextBuilder:
|
|||||||
1. Beaver user-facing assistant identity
|
1. Beaver user-facing assistant identity
|
||||||
2. base system prompt
|
2. base system prompt
|
||||||
3. session metadata
|
3. session metadata
|
||||||
4. execution context
|
4. runtime date/time
|
||||||
5. frozen memory snapshot
|
5. execution context
|
||||||
6. extra sections
|
6. frozen memory snapshot
|
||||||
|
7. extra sections
|
||||||
|
|
||||||
这样设计的原因:
|
这样设计的原因:
|
||||||
- 身份与总规则要最靠前
|
- 身份与总规则要最靠前
|
||||||
@ -164,6 +181,10 @@ class ContextBuilder:
|
|||||||
if session_section:
|
if session_section:
|
||||||
sections.append(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()
|
execution_context = (build_input.execution_context or "").strip()
|
||||||
if execution_context:
|
if execution_context:
|
||||||
sections.append(f"# Execution Context\n\n{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}")
|
rows.append(f"User ID: {session_context.user_id}")
|
||||||
if session_context.channel:
|
if session_context.channel:
|
||||||
rows.append(f"Channel: {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:
|
if session_context.chat_id:
|
||||||
rows.append(f"Chat ID: {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:
|
if session_context.parent_session_id:
|
||||||
rows.append(f"Parent Session ID: {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 None
|
||||||
return "# Current Session\n\n" + "\n".join(rows)
|
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]]:
|
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
|
||||||
"""把已激活 skill 转成显式消息。
|
"""把已激活 skill 转成显式消息。
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from beaver.skills.learning.eval import SkillDraftEvaluator
|
|||||||
from beaver.skills.publisher import SkillPublisher
|
from beaver.skills.publisher import SkillPublisher
|
||||||
from beaver.skills.reviews import ReviewService
|
from beaver.skills.reviews import ReviewService
|
||||||
from beaver.skills.specs import SkillSpecStore
|
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.tasks.skill_resolver import TaskSkillResolver
|
||||||
from beaver.skills import SkillAssembler, SkillsLoader
|
from beaver.skills import SkillAssembler, SkillsLoader
|
||||||
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
|
||||||
@ -44,6 +44,7 @@ from beaver.tools.builtins import (
|
|||||||
SpawnTool,
|
SpawnTool,
|
||||||
SessionSearchTool,
|
SessionSearchTool,
|
||||||
SkillManageTool,
|
SkillManageTool,
|
||||||
|
SkillViewTool,
|
||||||
SkillsListTool,
|
SkillsListTool,
|
||||||
TerminalTool,
|
TerminalTool,
|
||||||
TodoTool,
|
TodoTool,
|
||||||
@ -91,7 +92,6 @@ class EngineLoadResult:
|
|||||||
task_skill_resolver: TaskSkillResolver | None = None
|
task_skill_resolver: TaskSkillResolver | None = None
|
||||||
task_service: TaskService | None = None
|
task_service: TaskService | None = None
|
||||||
task_execution_planner: TaskExecutionPlanner | None = None
|
task_execution_planner: TaskExecutionPlanner | None = None
|
||||||
validation_service: ValidationService | None = None
|
|
||||||
mcp_manager: MCPConnectionManager | None = None
|
mcp_manager: MCPConnectionManager | None = None
|
||||||
mcp_report: dict[str, dict] = field(default_factory=dict)
|
mcp_report: dict[str, dict] = field(default_factory=dict)
|
||||||
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
|
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_skill_resolver: TaskSkillResolver | None = None,
|
||||||
task_service: TaskService | None = None,
|
task_service: TaskService | None = None,
|
||||||
task_execution_planner: TaskExecutionPlanner | None = None,
|
task_execution_planner: TaskExecutionPlanner | None = None,
|
||||||
validation_service: ValidationService | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
self.config = config or load_config(workspace=workspace, config_path=config_path)
|
||||||
configured_workspace = self.config.agents_defaults.workspace
|
configured_workspace = self.config.agents_defaults.workspace
|
||||||
@ -192,7 +191,6 @@ class EngineLoader:
|
|||||||
self._task_skill_resolver = task_skill_resolver
|
self._task_skill_resolver = task_skill_resolver
|
||||||
self._task_service = task_service
|
self._task_service = task_service
|
||||||
self._task_execution_planner = task_execution_planner
|
self._task_execution_planner = task_execution_planner
|
||||||
self._validation_service = validation_service
|
|
||||||
|
|
||||||
def load(self) -> EngineLoadResult:
|
def load(self) -> EngineLoadResult:
|
||||||
"""装配当前主链需要的最小 runtime 对象。"""
|
"""装配当前主链需要的最小 runtime 对象。"""
|
||||||
@ -233,6 +231,7 @@ class EngineLoader:
|
|||||||
ObjectBackedTool(DelegateTool()),
|
ObjectBackedTool(DelegateTool()),
|
||||||
ObjectBackedTool(SpawnTool()),
|
ObjectBackedTool(SpawnTool()),
|
||||||
SkillsListTool(),
|
SkillsListTool(),
|
||||||
|
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
|
||||||
SkillManageTool(),
|
SkillManageTool(),
|
||||||
CronTool(),
|
CronTool(),
|
||||||
]
|
]
|
||||||
@ -276,7 +275,6 @@ class EngineLoader:
|
|||||||
)
|
)
|
||||||
task_service = self._task_service or TaskService(workspace / "tasks")
|
task_service = self._task_service or TaskService(workspace / "tasks")
|
||||||
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
|
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(
|
mcp_manager = MCPConnectionManager(
|
||||||
self.config.tools.mcp_servers,
|
self.config.tools.mcp_servers,
|
||||||
authz_config=self.config.authz,
|
authz_config=self.config.authz,
|
||||||
@ -311,7 +309,6 @@ class EngineLoader:
|
|||||||
task_skill_resolver=task_skill_resolver,
|
task_skill_resolver=task_skill_resolver,
|
||||||
task_service=task_service,
|
task_service=task_service,
|
||||||
task_execution_planner=task_execution_planner,
|
task_execution_planner=task_execution_planner,
|
||||||
validation_service=validation_service,
|
|
||||||
mcp_manager=mcp_manager,
|
mcp_manager=mcp_manager,
|
||||||
)
|
)
|
||||||
if self._session_manager is None:
|
if self._session_manager is None:
|
||||||
|
|||||||
@ -4,12 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
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.memory.runs import RunRecord, SkillEffectRecord
|
||||||
from beaver.skills.learning import RunReceiptContext
|
from beaver.skills.learning import RunReceiptContext
|
||||||
from beaver.skills.catalog.utils import strip_frontmatter
|
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."
|
"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)
|
@dataclass(slots=True)
|
||||||
class AgentProfile:
|
class AgentProfile:
|
||||||
@ -34,9 +49,10 @@ class AgentProfile:
|
|||||||
name: str = "default"
|
name: str = "default"
|
||||||
system_prompt: str = ""
|
system_prompt: str = ""
|
||||||
default_model: str = "gpt-4.1-mini"
|
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
|
temperature: float = 0.2
|
||||||
max_tool_iterations: int = 8
|
max_tool_iterations: int = 30
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -74,6 +90,7 @@ class AgentLoop:
|
|||||||
self.loaded: EngineLoadResult | None = None
|
self.loaded: EngineLoadResult | None = None
|
||||||
self.runtime_services: dict[str, Any] = {}
|
self.runtime_services: dict[str, Any] = {}
|
||||||
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
|
||||||
|
self._active_direct_task: asyncio.Task[Any] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
|
|
||||||
@ -115,6 +132,8 @@ class AgentLoop:
|
|||||||
if item.future.cancelled():
|
if item.future.cancelled():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
previous_direct_task = self._active_direct_task
|
||||||
|
self._active_direct_task = asyncio.current_task()
|
||||||
try:
|
try:
|
||||||
result = await self._process_direct_impl(item.task, **item.kwargs)
|
result = await self._process_direct_impl(item.task, **item.kwargs)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@ -127,6 +146,8 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
if not item.future.done():
|
if not item.future.done():
|
||||||
item.future.set_result(result)
|
item.future.set_result(result)
|
||||||
|
finally:
|
||||||
|
self._active_direct_task = previous_direct_task
|
||||||
finally:
|
finally:
|
||||||
if self._run_queue is not None:
|
if self._run_queue is not None:
|
||||||
while True:
|
while True:
|
||||||
@ -168,6 +189,9 @@ class AgentLoop:
|
|||||||
if self._stop_requested:
|
if self._stop_requested:
|
||||||
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
|
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()
|
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
|
||||||
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
|
||||||
return await future
|
return await future
|
||||||
@ -225,6 +249,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""跑通最小 direct run 主链。
|
"""跑通最小 direct run 主链。
|
||||||
|
|
||||||
@ -274,6 +299,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts=pinned_skill_contexts,
|
pinned_skill_contexts=pinned_skill_contexts,
|
||||||
allow_candidate_generation=allow_candidate_generation,
|
allow_candidate_generation=allow_candidate_generation,
|
||||||
intent_agent_decision=intent_agent_decision,
|
intent_agent_decision=intent_agent_decision,
|
||||||
|
channel_identity=channel_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _process_direct_impl(
|
async def _process_direct_impl(
|
||||||
@ -311,6 +337,7 @@ class AgentLoop:
|
|||||||
pinned_skill_contexts: list[SkillContext] | None = None,
|
pinned_skill_contexts: list[SkillContext] | None = None,
|
||||||
allow_candidate_generation: bool = False,
|
allow_candidate_generation: bool = False,
|
||||||
intent_agent_decision: dict[str, Any] | None = None,
|
intent_agent_decision: dict[str, Any] | None = None,
|
||||||
|
channel_identity: ChannelIdentity | None = None,
|
||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
"""真正执行一轮 direct run 的内部实现。
|
"""真正执行一轮 direct run 的内部实现。
|
||||||
|
|
||||||
@ -348,7 +375,7 @@ class AgentLoop:
|
|||||||
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
|
||||||
resolved_embedding_model = embedding_model or config.default_embedding_model
|
resolved_embedding_model = embedding_model or config.default_embedding_model
|
||||||
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
|
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_temperature = self.profile.temperature if temperature is None else temperature
|
||||||
resolved_max_tool_iterations = (
|
resolved_max_tool_iterations = (
|
||||||
self.profile.max_tool_iterations if max_tool_iterations is None else 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 []),
|
*(pinned_skill_contexts or []),
|
||||||
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names 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, [])
|
activated_skills = self._merge_skill_contexts(pinned_skills, [])
|
||||||
else:
|
else:
|
||||||
skill_query = skill_selection_context or task
|
skill_query = skill_selection_context or task
|
||||||
@ -512,8 +539,6 @@ class AgentLoop:
|
|||||||
|
|
||||||
if not include_tools:
|
if not include_tools:
|
||||||
selected_tool_specs = []
|
selected_tool_specs = []
|
||||||
elif thinking_enabled is False:
|
|
||||||
selected_tool_specs = tool_registry.list_specs()
|
|
||||||
else:
|
else:
|
||||||
selected_tool_specs = await tool_assembler.assemble(
|
selected_tool_specs = await tool_assembler.assemble(
|
||||||
task_description=task,
|
task_description=task,
|
||||||
@ -543,7 +568,10 @@ class AgentLoop:
|
|||||||
|
|
||||||
build_input = ContextBuildInput(
|
build_input = ContextBuildInput(
|
||||||
base_system_prompt=self.profile.system_prompt,
|
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,
|
current_user_input=task,
|
||||||
memory_snapshot=memory_snapshot,
|
memory_snapshot=memory_snapshot,
|
||||||
activated_skills=activated_skills,
|
activated_skills=activated_skills,
|
||||||
@ -552,8 +580,16 @@ class AgentLoop:
|
|||||||
source=source,
|
source=source,
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
user_id=user_id,
|
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,
|
parent_session_id=parent_session_id,
|
||||||
),
|
),
|
||||||
|
runtime_context=self._current_runtime_context(),
|
||||||
execution_context=execution_context,
|
execution_context=execution_context,
|
||||||
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
|
||||||
)
|
)
|
||||||
@ -693,6 +729,7 @@ class AgentLoop:
|
|||||||
tool_calls=assistant_tool_calls or None,
|
tool_calls=assistant_tool_calls or None,
|
||||||
finish_reason=response.finish_reason,
|
finish_reason=response.finish_reason,
|
||||||
reasoning=response.reasoning_content,
|
reasoning=response.reasoning_content,
|
||||||
|
context_visible=not bool(assistant_tool_calls),
|
||||||
source=source,
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
model=final_model,
|
model=final_model,
|
||||||
@ -707,6 +744,10 @@ class AgentLoop:
|
|||||||
|
|
||||||
if not response.has_tool_calls:
|
if not response.has_tool_calls:
|
||||||
final_text = response.content or ""
|
final_text = response.content or ""
|
||||||
|
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"
|
final_finish_reason = response.finish_reason or "stop"
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -719,10 +760,7 @@ class AgentLoop:
|
|||||||
temperature=resolved_temperature,
|
temperature=resolved_temperature,
|
||||||
thinking_enabled=thinking_enabled,
|
thinking_enabled=thinking_enabled,
|
||||||
)
|
)
|
||||||
final_text = finalized or (
|
final_text = finalized or RAW_TOOL_CALL_FALLBACK
|
||||||
"Tool loop stopped after reaching the configured iteration limit, "
|
|
||||||
"and no final answer was produced."
|
|
||||||
)
|
|
||||||
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
|
final_finish_reason = "max_tool_iterations_finalized" if finalized else "max_tool_iterations"
|
||||||
session_manager.append_message(
|
session_manager.append_message(
|
||||||
resolved_session_id,
|
resolved_session_id,
|
||||||
@ -873,21 +911,18 @@ class AgentLoop:
|
|||||||
provider: Any,
|
provider: Any,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
model: str,
|
model: str,
|
||||||
max_tokens: int,
|
max_tokens: int | None,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
thinking_enabled: bool | None,
|
thinking_enabled: bool | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
final_messages = [
|
final_messages = AgentLoop._with_system_guidance(
|
||||||
*messages,
|
messages,
|
||||||
{
|
(
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"The configured tool iteration budget is exhausted. Do not call tools. "
|
"The configured tool iteration budget is exhausted. Do not call tools. "
|
||||||
"Produce the best final answer from the existing conversation and tool results. "
|
"Produce the best final answer from the existing conversation and tool results. "
|
||||||
"State uncertainty explicitly."
|
"State uncertainty explicitly."
|
||||||
),
|
),
|
||||||
},
|
)
|
||||||
]
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"messages": final_messages,
|
"messages": final_messages,
|
||||||
"tools": None,
|
"tools": None,
|
||||||
@ -898,7 +933,27 @@ class AgentLoop:
|
|||||||
if thinking_enabled is not None:
|
if thinking_enabled is not None:
|
||||||
kwargs["thinking_enabled"] = thinking_enabled
|
kwargs["thinking_enabled"] = thinking_enabled
|
||||||
response = await provider.chat(**kwargs)
|
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
|
@staticmethod
|
||||||
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
|
def _load_pinned_skill_contexts(skills_loader: Any, skill_names: list[str]) -> list[SkillContext]:
|
||||||
@ -1133,3 +1188,49 @@ class AgentLoop:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _utc_now() -> str:
|
def _utc_now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
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:]}"
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
|
|||||||
"model": model or self.default_model,
|
"model": model or self.default_model,
|
||||||
"system": system_prompt or "",
|
"system": system_prompt or "",
|
||||||
"messages": anthropic_messages,
|
"messages": anthropic_messages,
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"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:
|
if tools:
|
||||||
kwargs["tools"] = _convert_tools(tools)
|
kwargs["tools"] = _convert_tools(tools)
|
||||||
|
|
||||||
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
|
|||||||
return self.default_model
|
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]]]:
|
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
converted: list[dict[str, Any]] = []
|
converted: list[dict[str, Any]] = []
|
||||||
|
|||||||
@ -88,7 +88,7 @@ class LLMProvider(ABC):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
tools: list[dict] | None = None,
|
tools: list[dict] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
|
|||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
tools: list[dict] | None,
|
tools: list[dict] | None,
|
||||||
model: str,
|
model: str,
|
||||||
max_tokens: int,
|
max_tokens: int | None,
|
||||||
temperature: float,
|
temperature: float,
|
||||||
thinking_enabled: bool | None,
|
thinking_enabled: bool | None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
|||||||
@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model or self.default_model,
|
"model": model or self.default_model,
|
||||||
"messages": self.sanitize_empty_content(messages),
|
"messages": self.sanitize_empty_content(messages),
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
|
if max_tokens is not None:
|
||||||
|
kwargs["max_tokens"] = max(1, max_tokens)
|
||||||
if tools:
|
if tools:
|
||||||
kwargs.update(tools=tools, tool_choice="auto")
|
kwargs.update(tools=tools, tool_choice="auto")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -119,13 +119,23 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
sanitized = []
|
sanitized = []
|
||||||
|
system_contents: list[str] = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
|
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:
|
if clean.get("role") == "assistant" and "content" not in clean:
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
if isinstance(clean.get("tool_calls"), list):
|
if isinstance(clean.get("tool_calls"), list):
|
||||||
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
|
||||||
sanitized.append(clean)
|
sanitized.append(clean)
|
||||||
|
if system_contents:
|
||||||
|
sanitized.insert(0, {"role": "system", "content": "\n\n".join(system_contents)})
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -187,7 +197,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int | None = None,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
thinking_enabled: bool | None = None,
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@ -200,10 +210,11 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": resolved_model,
|
"model": resolved_model,
|
||||||
"messages": sanitized_messages,
|
"messages": sanitized_messages,
|
||||||
"max_tokens": max(1, max_tokens),
|
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"timeout": self.request_timeout_seconds or 45.0,
|
"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:
|
if self.api_key:
|
||||||
kwargs["api_key"] = self.api_key
|
kwargs["api_key"] = self.api_key
|
||||||
if self.api_base:
|
if self.api_base:
|
||||||
|
|||||||
@ -84,8 +84,10 @@ class MessageRecord:
|
|||||||
payload["task_id"] = self.event_payload.get("task_id")
|
payload["task_id"] = self.event_payload.get("task_id")
|
||||||
if self.event_payload.get("task_status"):
|
if self.event_payload.get("task_status"):
|
||||||
payload["task_status"] = self.event_payload.get("task_status")
|
payload["task_status"] = self.event_payload.get("task_status")
|
||||||
if self.event_payload.get("validation_status"):
|
if self.event_payload.get("evidence_status"):
|
||||||
payload["validation_status"] = self.event_payload.get("validation_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"):
|
if self.event_payload.get("feedback_state"):
|
||||||
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
payload["feedback_state"] = self.event_payload.get("feedback_state")
|
||||||
if self.event_payload.get("feedback_error"):
|
if self.event_payload.get("feedback_error"):
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from .schema import (
|
|||||||
AuthzConfig,
|
AuthzConfig,
|
||||||
BackendIdentityConfig,
|
BackendIdentityConfig,
|
||||||
BeaverConfig,
|
BeaverConfig,
|
||||||
|
ChannelConfig,
|
||||||
EmbeddingConfig,
|
EmbeddingConfig,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
@ -73,6 +74,7 @@ def load_config(
|
|||||||
embedding=_parse_embedding(data),
|
embedding=_parse_embedding(data),
|
||||||
tools=_parse_tools(data.get("tools")),
|
tools=_parse_tools(data.get("tools")),
|
||||||
authz=_parse_authz(data.get("authz")),
|
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")),
|
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
|
||||||
config_path=path,
|
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")),
|
model=_string(defaults.get("model") or data.get("model")),
|
||||||
provider=_string(defaults.get("provider") or data.get("provider")),
|
provider=_string(defaults.get("provider") or data.get("provider")),
|
||||||
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
|
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:
|
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
|
||||||
data = _as_dict(raw)
|
data = _as_dict(raw)
|
||||||
return BackendIdentityConfig(
|
return BackendIdentityConfig(
|
||||||
@ -192,6 +255,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
|
|||||||
return value if isinstance(value, dict) else {}
|
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:
|
def _string(value: Any) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -217,6 +287,13 @@ def _float(value: Any) -> float | None:
|
|||||||
return float(value)
|
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:
|
def _bool(value: Any, *, default: bool) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -25,6 +25,10 @@ class AgentDefaultsConfig:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
provider: str | None = None
|
provider: str | None = None
|
||||||
embedding_model: 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)
|
@dataclass(slots=True)
|
||||||
@ -87,6 +91,19 @@ class AuthzConfig:
|
|||||||
outlook_mcp_url: str = ""
|
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)
|
@dataclass(slots=True)
|
||||||
class BackendIdentityConfig:
|
class BackendIdentityConfig:
|
||||||
"""This backend's AuthZ client identity."""
|
"""This backend's AuthZ client identity."""
|
||||||
@ -107,6 +124,7 @@ class BeaverConfig:
|
|||||||
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
||||||
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
||||||
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
authz: AuthzConfig = field(default_factory=AuthzConfig)
|
||||||
|
channels: dict[str, ChannelConfig] = field(default_factory=dict)
|
||||||
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
|
||||||
config_path: Path | None = None
|
config_path: Path | None = None
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
"""Event contracts and dispatch helpers."""
|
"""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"]
|
||||||
|
|||||||
@ -9,12 +9,58 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
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)
|
@dataclass(slots=True)
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
"""A minimal inbound message accepted by the gateway bridge."""
|
"""A minimal inbound message accepted by the gateway bridge."""
|
||||||
|
|
||||||
channel: str
|
channel: str
|
||||||
content: str
|
content: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@ -35,6 +81,8 @@ class OutboundMessage:
|
|||||||
content: str
|
content: str
|
||||||
session_id: str | None
|
session_id: str | None
|
||||||
finish_reason: str
|
finish_reason: str
|
||||||
|
content_type: str = "text"
|
||||||
|
channel_identity: ChannelIdentity | None = None
|
||||||
message_id: str = field(default_factory=lambda: str(uuid4()))
|
message_id: str = field(default_factory=lambda: str(uuid4()))
|
||||||
run_id: str | None = None
|
run_id: str | None = None
|
||||||
provider_name: str | None = None
|
provider_name: str | None = None
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
"""Channel interfaces."""
|
"""Channel interfaces."""
|
||||||
|
|
||||||
from .base import ChannelAdapter
|
from .base import ChannelAdapter
|
||||||
|
from .base import ChannelInboundSink
|
||||||
|
from .external_connector import ExternalConnectorChannel
|
||||||
from .manager import ChannelManager
|
from .manager import ChannelManager
|
||||||
from .memory import MemoryChannelAdapter
|
from .memory import MemoryChannelAdapter
|
||||||
|
from .terminal_websocket import TerminalWebSocketAdapter
|
||||||
|
|
||||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
__all__ = [
|
||||||
|
"ChannelAdapter",
|
||||||
|
"ChannelInboundSink",
|
||||||
|
"ExternalConnectorChannel",
|
||||||
|
"ChannelManager",
|
||||||
|
"MemoryChannelAdapter",
|
||||||
|
"TerminalWebSocketAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@ -2,16 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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):
|
class ChannelAdapter(Protocol):
|
||||||
"""Minimal contract every gateway channel must implement."""
|
"""Minimal contract every runtime channel adapter must implement."""
|
||||||
|
|
||||||
name: str
|
channel_id: str
|
||||||
bus: MessageBus
|
kind: str
|
||||||
|
mode: str
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Prepare the channel before messages are routed."""
|
"""Prepare the channel before messages are routed."""
|
||||||
@ -22,3 +23,9 @@ class ChannelAdapter(Protocol):
|
|||||||
async def send(self, message: OutboundMessage) -> None:
|
async def send(self, message: OutboundMessage) -> None:
|
||||||
"""Deliver an outbound message to the concrete channel."""
|
"""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."""
|
||||||
|
|||||||
@ -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",
|
||||||
|
]
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -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"]
|
||||||
@ -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
|
||||||
@ -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()
|
||||||
@ -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"
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from beaver.foundation.events import MessageBus, OutboundMessage
|
from beaver.foundation.events import MessageBus, OutboundMessage
|
||||||
@ -20,13 +21,17 @@ class ChannelManager:
|
|||||||
self.started = False
|
self.started = False
|
||||||
|
|
||||||
def register(self, channel: ChannelAdapter) -> None:
|
def register(self, channel: ChannelAdapter) -> None:
|
||||||
if self.started:
|
if channel.channel_id in self.channels:
|
||||||
raise RuntimeError("Cannot register channels after ChannelManager.start()")
|
raise ValueError(f"Channel already registered: {channel.channel_id}")
|
||||||
if channel.name in self.channels:
|
self.channels[channel.channel_id] = channel
|
||||||
raise ValueError(f"Channel already registered: {channel.name}")
|
|
||||||
if channel.bus is not self.bus:
|
def unregister(self, channel_id: str) -> ChannelAdapter | None:
|
||||||
raise ValueError("Channel must share the same MessageBus as ChannelManager")
|
return self.channels.pop(channel_id, None)
|
||||||
self.channels[channel.name] = channel
|
|
||||||
|
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:
|
async def start(self) -> None:
|
||||||
started: list[ChannelAdapter] = []
|
started: list[ChannelAdapter] = []
|
||||||
@ -53,7 +58,13 @@ class ChannelManager:
|
|||||||
if errors:
|
if errors:
|
||||||
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
|
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."""
|
"""Route bus outbound messages until stopped and the queue is drained."""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
@ -68,9 +79,16 @@ class ChannelManager:
|
|||||||
channel = self.channels.get(message.channel)
|
channel = self.channels.get(message.channel)
|
||||||
if channel is None:
|
if channel is None:
|
||||||
self.undeliverable.append(message)
|
self.undeliverable.append(message)
|
||||||
|
if on_failed is not None:
|
||||||
|
await on_failed(message, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await channel.send(message)
|
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)
|
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)
|
||||||
|
|||||||
@ -4,15 +4,27 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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:
|
class MemoryChannelAdapter:
|
||||||
"""A local channel that stores outbound messages in memory."""
|
"""A local channel that stores outbound messages in memory."""
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
|
def __init__(
|
||||||
self.name = name
|
self,
|
||||||
self.bus = bus
|
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.started = False
|
||||||
self.sent_messages: list[OutboundMessage] = []
|
self.sent_messages: list[OutboundMessage] = []
|
||||||
|
|
||||||
@ -36,12 +48,24 @@ class MemoryChannelAdapter:
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
provider_name: str | None = None,
|
provider_name: str | None = None,
|
||||||
embedding_model: 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,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> InboundMessage:
|
) -> InboundMessage:
|
||||||
"""Publish a text message from this channel into the shared bus."""
|
"""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(
|
message = InboundMessage(
|
||||||
channel=self.name,
|
channel=self.channel_id,
|
||||||
content=content,
|
content=content,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@ -50,9 +74,10 @@ class MemoryChannelAdapter:
|
|||||||
model=model,
|
model=model,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
embedding_model=embedding_model,
|
embedding_model=embedding_model,
|
||||||
|
channel_identity=identity,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
await self.bus.publish_inbound(message)
|
await self.inbound_sink.accept_inbound(message)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
async def publish_external_text(
|
async def publish_external_text(
|
||||||
@ -73,9 +98,6 @@ class MemoryChannelAdapter:
|
|||||||
the shared gateway bus.
|
the shared gateway bus.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
session_parts = [self.name, chat_id]
|
|
||||||
if thread_id:
|
|
||||||
session_parts.append(thread_id)
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
@ -84,8 +106,10 @@ class MemoryChannelAdapter:
|
|||||||
}
|
}
|
||||||
return await self.publish_text(
|
return await self.publish_text(
|
||||||
content,
|
content,
|
||||||
session_id=":".join(str(part) for part in session_parts if str(part)),
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
peer_id=chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=message_id,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
"""Platform channel adapters."""
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal file
526
app-instance/backend/beaver/interfaces/channels/runtime.py
Normal 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
|
||||||
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal file
198
app-instance/backend/beaver/interfaces/channels/state.py
Normal 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))
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -19,6 +19,18 @@ from typing import Any
|
|||||||
|
|
||||||
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
||||||
from beaver.foundation.config import default_config_path, load_config
|
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.foundation.models import CronExecutionResult, CronRunRecord
|
||||||
from beaver.integrations.mcp import MCPConnectionManager
|
from beaver.integrations.mcp import MCPConnectionManager
|
||||||
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
|
||||||
@ -44,11 +56,25 @@ from .files import (
|
|||||||
workspace_file_path,
|
workspace_file_path,
|
||||||
)
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
|
WebChatAcceptanceRequest,
|
||||||
|
WebChatAcceptanceResponse,
|
||||||
WebChatFeedbackRequest,
|
WebChatFeedbackRequest,
|
||||||
WebChatFeedbackResponse,
|
WebChatFeedbackResponse,
|
||||||
WebChatRequest,
|
WebChatRequest,
|
||||||
WebChatResponse,
|
WebChatResponse,
|
||||||
WebErrorResponse,
|
WebErrorResponse,
|
||||||
|
WebAgentConfigRequest,
|
||||||
|
WebAgentConfigResponse,
|
||||||
|
WebChannelConfigRequest,
|
||||||
|
WebChannelConfigResponse,
|
||||||
|
WebChannelConnectionCreateRequest,
|
||||||
|
WebChannelConnectionResponse,
|
||||||
|
WebChannelConnectionUpdateRequest,
|
||||||
|
WebChannelValidationResponse,
|
||||||
|
WebConnectorBridgeEventRequest,
|
||||||
|
WebConnectorBridgeEventResponse,
|
||||||
|
WebConnectorSessionCreateRequest,
|
||||||
|
WebConnectorSessionResponse,
|
||||||
WebProviderConfigRequest,
|
WebProviderConfigRequest,
|
||||||
WebProviderConfigResponse,
|
WebProviderConfigResponse,
|
||||||
WebStatusResponse,
|
WebStatusResponse,
|
||||||
@ -56,7 +82,7 @@ from .schemas import (
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
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
|
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
|
||||||
def File(default: Any = None) -> Any: # type: ignore[override]
|
def File(default: Any = None) -> Any: # type: ignore[override]
|
||||||
return default
|
return default
|
||||||
@ -90,6 +116,11 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
|||||||
self.media_type = media_type
|
self.media_type = media_type
|
||||||
self.headers = headers or {}
|
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):
|
class WebSocketDisconnect(Exception):
|
||||||
"""Fallback websocket disconnect exception."""
|
"""Fallback websocket disconnect exception."""
|
||||||
|
|
||||||
@ -155,6 +186,13 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
|||||||
return decorator
|
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
|
@asynccontextmanager
|
||||||
async def _app_lifespan(
|
async def _app_lifespan(
|
||||||
app: FastAPI,
|
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
|
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||||
app.state.agent_service = attached_service
|
app.state.agent_service = attached_service
|
||||||
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
|
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
|
||||||
|
app.state.channel_runtime = None
|
||||||
started = False
|
started = False
|
||||||
|
channel_runtime: ChannelRuntime | None = None
|
||||||
if owns_service:
|
if owns_service:
|
||||||
try:
|
try:
|
||||||
await attached_service.start()
|
await attached_service.start()
|
||||||
@ -189,6 +229,29 @@ async def _app_lifespan(
|
|||||||
else:
|
else:
|
||||||
attached_service.close()
|
attached_service.close()
|
||||||
raise
|
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: SkillLearningWorker | None = None
|
||||||
worker_task = None
|
worker_task = None
|
||||||
worker_config = SkillLearningWorkerConfig.from_env()
|
worker_config = SkillLearningWorkerConfig.from_env()
|
||||||
@ -205,6 +268,10 @@ async def _app_lifespan(
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
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)
|
cron_service = getattr(app.state, "cron_service", None)
|
||||||
if isinstance(cron_service, CronService):
|
if isinstance(cron_service, CronService):
|
||||||
cron_service.stop()
|
cron_service.stop()
|
||||||
@ -272,6 +339,118 @@ def get_cron_service(request: Request) -> CronService:
|
|||||||
return service
|
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(
|
def create_app(
|
||||||
*,
|
*,
|
||||||
workspace: str | Path | None = None,
|
workspace: str | Path | None = None,
|
||||||
@ -365,13 +544,334 @@ def create_app(
|
|||||||
"workspace_exists": loaded.workspace.exists(),
|
"workspace_exists": loaded.workspace.exists(),
|
||||||
"model": config.default_model or agent_service.profile.default_model,
|
"model": config.default_model or agent_service.profile.default_model,
|
||||||
"max_tokens": agent_service.profile.max_tokens,
|
"max_tokens": agent_service.profile.max_tokens,
|
||||||
|
"max_context_messages": agent_service.profile.max_context_messages,
|
||||||
"temperature": agent_service.profile.temperature,
|
"temperature": agent_service.profile.temperature,
|
||||||
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
"max_tool_iterations": agent_service.profile.max_tool_iterations,
|
||||||
"providers": providers_status,
|
"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(),
|
"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")
|
@app.post("/api/auth/login")
|
||||||
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
username = _clean_text(payload.get("username"))
|
username = _clean_text(payload.get("username"))
|
||||||
@ -585,6 +1085,38 @@ def create_app(
|
|||||||
_reload_agent_config(agent_service, config_path)
|
_reload_agent_config(agent_service, config_path)
|
||||||
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
|
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")
|
@app.get("/api/sessions")
|
||||||
async def list_sessions(request: Request) -> list[dict[str, Any]]:
|
async def list_sessions(request: Request) -> list[dict[str, Any]]:
|
||||||
loaded = get_agent_service(request).create_loop().boot()
|
loaded = get_agent_service(request).create_loop().boot()
|
||||||
@ -1719,7 +2251,8 @@ def create_app(
|
|||||||
usage=result.usage,
|
usage=result.usage,
|
||||||
task_id=result.task_id,
|
task_id=result.task_id,
|
||||||
task_status=result.task_status,
|
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)
|
fallback_target = _model_dump(payload.fallback_target)
|
||||||
@ -1769,7 +2302,8 @@ def create_app(
|
|||||||
usage=result.usage,
|
usage=result.usage,
|
||||||
task_id=result.task_id,
|
task_id=result.task_id,
|
||||||
task_status=result.task_status,
|
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}")
|
@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(
|
@app.post(
|
||||||
"/api/chat/feedback",
|
"/api/chat/feedback",
|
||||||
response_model=WebChatFeedbackResponse,
|
response_model=WebChatFeedbackResponse,
|
||||||
@ -1893,10 +2451,10 @@ def create_app(
|
|||||||
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
|
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
|
||||||
agent_service = get_agent_service(request)
|
agent_service = get_agent_service(request)
|
||||||
try:
|
try:
|
||||||
result = await agent_service.submit_feedback(
|
result = await agent_service.submit_acceptance(
|
||||||
session_id=payload.session_id,
|
session_id=payload.session_id,
|
||||||
run_id=payload.run_id,
|
run_id=payload.run_id,
|
||||||
feedback_type=payload.feedback_type,
|
acceptance_type=payload.feedback_type,
|
||||||
comment=payload.comment,
|
comment=payload.comment,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
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")
|
role = event.get("role")
|
||||||
if role not in {"user", "assistant"}:
|
if role not in {"user", "assistant"}:
|
||||||
continue
|
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(
|
messages.append(
|
||||||
{
|
{
|
||||||
"role": role,
|
"role": role,
|
||||||
"content": event.get("content") or "",
|
"content": content,
|
||||||
"timestamp": _iso_from_timestamp(event.get("timestamp")),
|
"timestamp": _iso_from_timestamp(event.get("timestamp")),
|
||||||
"run_id": event.get("run_id"),
|
"run_id": event.get("run_id"),
|
||||||
"task_id": event.get("task_id"),
|
"task_id": event.get("task_id"),
|
||||||
"task_status": event.get("task_status"),
|
"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_state": event.get("feedback_state"),
|
||||||
"feedback_error": event.get("feedback_error"),
|
"feedback_error": event.get("feedback_error"),
|
||||||
"message_type": event.get("message_type"),
|
"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()
|
content = (record.content or "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
continue
|
continue
|
||||||
|
content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
|
||||||
messages.append(
|
messages.append(
|
||||||
{
|
{
|
||||||
"role": record.role,
|
"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,
|
"tool_name": record.tool_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
validation = run_record.validation_result if run_record is not None else None
|
|
||||||
views.append(
|
views.append(
|
||||||
{
|
{
|
||||||
"run_id": run_id,
|
"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,
|
"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 "",
|
"task_text": run_record.task_text if run_record is not None else "",
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"validation_result": validation,
|
"evidence_status": "recorded",
|
||||||
|
"validation_result": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return views
|
return views
|
||||||
@ -2428,12 +2993,6 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
|
|||||||
return dict(value)
|
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]:
|
def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
||||||
result: dict[str, Any] = dict(metadata)
|
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]:
|
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_id = getattr(result, "task_id", None)
|
||||||
task_status = getattr(result, "task_status", None)
|
task_status = getattr(result, "task_status", None)
|
||||||
return {
|
return {
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"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),
|
"session_id": getattr(result, "session_id", None),
|
||||||
"run_id": getattr(result, "run_id", None),
|
"run_id": getattr(result, "run_id", None),
|
||||||
"finish_reason": getattr(result, "finish_reason", 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 {}),
|
"usage": dict(getattr(result, "usage", {}) or {}),
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"task_status": task_status,
|
"task_status": task_status,
|
||||||
"validation_result": validation_result,
|
"evidence_status": "recorded" if task_id else None,
|
||||||
"validation_status": _validation_status(validation_result),
|
"validation_result": None,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"task_status": task_status,
|
"task_status": task_status,
|
||||||
"validation_result": validation_result,
|
"evidence_status": "recorded" if task_id else None,
|
||||||
"input_metadata": _websocket_input_metadata(input_payload),
|
"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:
|
def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
|
||||||
if provider_cfg is None or provider_name == "custom":
|
if provider_cfg is None or provider_name == "custom":
|
||||||
return False
|
return False
|
||||||
@ -2916,6 +3499,25 @@ def _mask_secret(value: str | None) -> str:
|
|||||||
return f"{secret[:4]}••••{secret[-4:]}"
|
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]:
|
def _read_config_json(path: Path) -> dict[str, Any]:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {}
|
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:
|
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
|
||||||
config = load_config(config_path=config_path)
|
config = load_config(config_path=config_path)
|
||||||
agent_service.loader.config = config
|
agent_service.loader.config = config
|
||||||
|
agent_service._apply_configured_profile_defaults() # noqa: SLF001
|
||||||
loop = getattr(agent_service, "_loop", None)
|
loop = getattr(agent_service, "_loop", None)
|
||||||
loaded = getattr(loop, "loaded", None) if loop is not None else None
|
loaded = getattr(loop, "loaded", None) if loop is not None else None
|
||||||
if loaded is not None:
|
if loaded is not None:
|
||||||
old_manager = getattr(loaded, "mcp_manager", None)
|
old_manager = getattr(loaded, "mcp_manager", None)
|
||||||
if old_manager is not None:
|
if old_manager is not None:
|
||||||
async def _close_old_manager() -> None:
|
async def _close_old_manager() -> None:
|
||||||
|
try:
|
||||||
await old_manager.close()
|
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:
|
try:
|
||||||
running_loop = asyncio.get_running_loop()
|
running_loop = asyncio.get_running_loop()
|
||||||
|
|||||||
@ -1,11 +1,25 @@
|
|||||||
"""Web request and response schemas."""
|
"""Web request and response schemas."""
|
||||||
|
|
||||||
from .chat import (
|
from .chat import (
|
||||||
|
WebChatAcceptanceRequest,
|
||||||
|
WebChatAcceptanceResponse,
|
||||||
WebChatFeedbackRequest,
|
WebChatFeedbackRequest,
|
||||||
WebChatFeedbackResponse,
|
WebChatFeedbackResponse,
|
||||||
WebChatRequest,
|
WebChatRequest,
|
||||||
WebChatResponse,
|
WebChatResponse,
|
||||||
WebErrorResponse,
|
WebErrorResponse,
|
||||||
|
WebAgentConfigRequest,
|
||||||
|
WebAgentConfigResponse,
|
||||||
|
WebChannelConfigRequest,
|
||||||
|
WebChannelConfigResponse,
|
||||||
|
WebChannelConnectionCreateRequest,
|
||||||
|
WebChannelConnectionResponse,
|
||||||
|
WebChannelConnectionUpdateRequest,
|
||||||
|
WebChannelValidationResponse,
|
||||||
|
WebConnectorBridgeEventRequest,
|
||||||
|
WebConnectorBridgeEventResponse,
|
||||||
|
WebConnectorSessionCreateRequest,
|
||||||
|
WebConnectorSessionResponse,
|
||||||
WebProviderConfigRequest,
|
WebProviderConfigRequest,
|
||||||
WebProviderConfigResponse,
|
WebProviderConfigResponse,
|
||||||
WebProviderTarget,
|
WebProviderTarget,
|
||||||
@ -13,11 +27,25 @@ from .chat import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"WebChatAcceptanceRequest",
|
||||||
|
"WebChatAcceptanceResponse",
|
||||||
"WebChatFeedbackRequest",
|
"WebChatFeedbackRequest",
|
||||||
"WebChatFeedbackResponse",
|
"WebChatFeedbackResponse",
|
||||||
"WebChatRequest",
|
"WebChatRequest",
|
||||||
"WebChatResponse",
|
"WebChatResponse",
|
||||||
"WebErrorResponse",
|
"WebErrorResponse",
|
||||||
|
"WebAgentConfigRequest",
|
||||||
|
"WebAgentConfigResponse",
|
||||||
|
"WebChannelConfigRequest",
|
||||||
|
"WebChannelConfigResponse",
|
||||||
|
"WebChannelConnectionCreateRequest",
|
||||||
|
"WebChannelConnectionResponse",
|
||||||
|
"WebChannelConnectionUpdateRequest",
|
||||||
|
"WebChannelValidationResponse",
|
||||||
|
"WebConnectorBridgeEventRequest",
|
||||||
|
"WebConnectorBridgeEventResponse",
|
||||||
|
"WebConnectorSessionCreateRequest",
|
||||||
|
"WebConnectorSessionResponse",
|
||||||
"WebProviderConfigRequest",
|
"WebProviderConfigRequest",
|
||||||
"WebProviderConfigResponse",
|
"WebProviderConfigResponse",
|
||||||
"WebProviderTarget",
|
"WebProviderTarget",
|
||||||
|
|||||||
@ -82,11 +82,34 @@ class WebChatResponse(BaseModel):
|
|||||||
usage: dict[str, Any] = Field(default_factory=dict)
|
usage: dict[str, Any] = Field(default_factory=dict)
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
task_status: 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
|
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):
|
class WebChatFeedbackRequest(BaseModel):
|
||||||
"""Feedback on the latest assistant result in chat."""
|
"""Backward-compatible feedback payload."""
|
||||||
|
|
||||||
session_id: str
|
session_id: str
|
||||||
run_id: str
|
run_id: str
|
||||||
@ -94,15 +117,8 @@ class WebChatFeedbackRequest(BaseModel):
|
|||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class WebChatFeedbackResponse(BaseModel):
|
class WebChatFeedbackResponse(WebChatAcceptanceResponse):
|
||||||
"""Feedback recording result."""
|
"""Backward-compatible feedback response."""
|
||||||
|
|
||||||
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 WebProviderConfigRequest(BaseModel):
|
class WebProviderConfigRequest(BaseModel):
|
||||||
@ -123,6 +139,127 @@ class WebProviderConfigResponse(BaseModel):
|
|||||||
enabled: bool
|
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):
|
class WebStatusResponse(BaseModel):
|
||||||
"""Web 宿主层状态响应。"""
|
"""Web 宿主层状态响应。"""
|
||||||
|
|
||||||
|
|||||||
@ -29,9 +29,9 @@ from beaver.tasks import (
|
|||||||
TaskEvidencePacket,
|
TaskEvidencePacket,
|
||||||
TaskExecutionPlan,
|
TaskExecutionPlan,
|
||||||
TaskRecord,
|
TaskRecord,
|
||||||
ValidationResult,
|
|
||||||
render_task_evidence,
|
render_task_evidence,
|
||||||
)
|
)
|
||||||
|
from beaver.tasks.service import normalize_acceptance_type
|
||||||
|
|
||||||
|
|
||||||
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
|
NOTIFICATION_SESSION_ID = "notify:default:scheduled"
|
||||||
@ -60,11 +60,27 @@ class AgentService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
self.profile = profile or AgentProfile()
|
self.profile = profile or AgentProfile()
|
||||||
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
|
self.loader = loader or EngineLoader(workspace=workspace, config_path=config_path)
|
||||||
|
self._apply_configured_profile_defaults()
|
||||||
self._loop: AgentLoop | None = None
|
self._loop: AgentLoop | None = None
|
||||||
self._run_task: asyncio.Task[None] | None = None
|
self._run_task: asyncio.Task[None] | None = None
|
||||||
self._main_agent_router = MainAgentRouter()
|
self._main_agent_router = MainAgentRouter()
|
||||||
self._runtime_services: dict[str, Any] = {}
|
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:
|
def create_loop(self) -> AgentLoop:
|
||||||
"""创建并缓存当前 service 使用的 AgentLoop。"""
|
"""创建并缓存当前 service 使用的 AgentLoop。"""
|
||||||
|
|
||||||
@ -232,7 +248,7 @@ class AgentService:
|
|||||||
|
|
||||||
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
|
Scheduled jobs are product-level Tasks, not hidden one-off agent turns.
|
||||||
This entry bypasses the main-agent classifier and forces Task mode so
|
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.
|
run_id that the scheduled-task history can link to.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -280,9 +296,9 @@ class AgentService:
|
|||||||
result.run_id,
|
result.run_id,
|
||||||
{
|
{
|
||||||
"message_type": "scheduled_reply",
|
"message_type": "scheduled_reply",
|
||||||
"scheduled_job_id": job.id,
|
"scheduled_job_id": cron_job_id,
|
||||||
"scheduled_run_id": run.scheduled_run_id,
|
"scheduled_run_id": scheduled_run_id,
|
||||||
"cron_job_name": job.name,
|
"cron_job_name": cron_job_name,
|
||||||
"mode": "notification",
|
"mode": "notification",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -403,15 +419,15 @@ class AgentService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def submit_feedback(
|
async def submit_acceptance(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
feedback_type: str,
|
acceptance_type: str,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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()
|
loaded = self.create_loop().boot()
|
||||||
task_service = self._require_loaded(loaded, "task_service")
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
@ -419,32 +435,31 @@ class AgentService:
|
|||||||
if task is None or task.session_id != session_id:
|
if task is None or task.session_id != session_id:
|
||||||
raise ValueError(f"No internal task found for run_id={run_id!r}")
|
raise ValueError(f"No internal task found for run_id={run_id!r}")
|
||||||
|
|
||||||
normalized = feedback_type.strip().lower()
|
normalized = normalize_acceptance_type(acceptance_type)
|
||||||
if normalized not in {"satisfied", "revise", "abandon"}:
|
legacy_feedback_type = "satisfied" if normalized == "accept" else normalized
|
||||||
raise ValueError("feedback_type must be one of: satisfied, revise, abandon")
|
|
||||||
|
|
||||||
already_recorded = any(
|
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
|
for item in task.feedback
|
||||||
)
|
)
|
||||||
conflicting_feedback = next(
|
conflicting_acceptance = next(
|
||||||
(
|
(
|
||||||
item
|
item
|
||||||
for item in task.feedback
|
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,
|
None,
|
||||||
)
|
)
|
||||||
if conflicting_feedback is not None:
|
if conflicting_acceptance is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Feedback for run_id={run_id!r} was already recorded as "
|
f"Acceptance for run_id={run_id!r} was already recorded as "
|
||||||
f"{conflicting_feedback.get('feedback_type')!r}"
|
f"{conflicting_acceptance.get('acceptance_type')!r}"
|
||||||
)
|
)
|
||||||
if task.status in {"closed", "abandoned"} and not already_recorded:
|
if task.status in {"closed", "abandoned"} and not already_recorded:
|
||||||
raise ValueError(f"Task {task.task_id} is already finalized as {task.status!r}")
|
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,
|
task.task_id,
|
||||||
feedback_type=normalized,
|
acceptance_type=normalized,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
@ -455,7 +470,8 @@ class AgentService:
|
|||||||
{
|
{
|
||||||
"task_id": updated.task_id,
|
"task_id": updated.task_id,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
"feedback_state": normalized,
|
"acceptance_state": normalized,
|
||||||
|
"feedback_state": legacy_feedback_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not already_recorded:
|
if not already_recorded:
|
||||||
@ -463,10 +479,11 @@ class AgentService:
|
|||||||
session_id,
|
session_id,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
role="system",
|
role="system",
|
||||||
event_type="task_feedback_recorded",
|
event_type="task_acceptance_recorded",
|
||||||
event_payload={
|
event_payload={
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
"feedback_type": normalized,
|
"acceptance_type": normalized,
|
||||||
|
"feedback_type": legacy_feedback_type,
|
||||||
"comment": comment,
|
"comment": comment,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
},
|
},
|
||||||
@ -475,35 +492,36 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
generated_candidates = []
|
generated_candidates = []
|
||||||
validation = ValidationResult.from_dict(updated.validation_result)
|
|
||||||
if not already_recorded:
|
if not already_recorded:
|
||||||
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||||
feedback_payload = {
|
acceptance_payload = {
|
||||||
"feedback_type": normalized,
|
"acceptance_type": normalized,
|
||||||
|
"feedback_type": legacy_feedback_type,
|
||||||
"comment": comment or "",
|
"comment": comment or "",
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
|
"final_accepted_run_id": updated.metadata.get("final_accepted_run_id"),
|
||||||
}
|
}
|
||||||
run_memory_store.update_run_record(
|
run_memory_store.update_run_record(
|
||||||
run_id,
|
run_id,
|
||||||
success=normalized == "satisfied",
|
success=normalized == "accept",
|
||||||
feedback=feedback_payload,
|
feedback=acceptance_payload,
|
||||||
)
|
)
|
||||||
run_memory_store.update_skill_effects_for_run(
|
run_memory_store.update_skill_effects_for_run(
|
||||||
run_id,
|
run_id,
|
||||||
success=normalized == "satisfied",
|
success=normalized == "accept",
|
||||||
feedback_score=self._feedback_score_for_learning(normalized, validation),
|
feedback_score=self._acceptance_score_for_learning(normalized),
|
||||||
notes=(comment or normalized).strip(),
|
notes=(comment or normalized).strip(),
|
||||||
)
|
)
|
||||||
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||||
skill_learning_service.rescore_skill_versions()
|
skill_learning_service.rescore_skill_versions()
|
||||||
if already_recorded:
|
if already_recorded:
|
||||||
generated_candidates = []
|
generated_candidates = []
|
||||||
elif normalized == "satisfied" and validation is not None and validation.accepted:
|
elif normalized == "accept":
|
||||||
generated_candidates = [
|
generated_candidates = [
|
||||||
item.to_dict()
|
item.to_dict()
|
||||||
for item in skill_learning_service.build_learning_candidates_for_task(
|
for item in skill_learning_service.build_learning_candidates_for_task(
|
||||||
updated.task_id,
|
updated.task_id,
|
||||||
trigger_run_id=run_id,
|
final_accepted_run_id=run_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
elif normalized == "abandon":
|
elif normalized == "abandon":
|
||||||
@ -514,7 +532,8 @@ class AgentService:
|
|||||||
event_type="task_failure_evidence_recorded",
|
event_type="task_failure_evidence_recorded",
|
||||||
event_payload={
|
event_payload={
|
||||||
"task_id": updated.task_id,
|
"task_id": updated.task_id,
|
||||||
"feedback_type": normalized,
|
"acceptance_type": normalized,
|
||||||
|
"feedback_type": legacy_feedback_type,
|
||||||
"comment": comment or "",
|
"comment": comment or "",
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
"durable_memory_written": False,
|
"durable_memory_written": False,
|
||||||
@ -528,10 +547,28 @@ class AgentService:
|
|||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"task_id": updated.task_id,
|
"task_id": updated.task_id,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
"feedback_type": normalized,
|
"acceptance_type": normalized,
|
||||||
|
"feedback_type": legacy_feedback_type,
|
||||||
"learning_candidates": generated_candidates,
|
"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(
|
async def _process_with_main_agent(
|
||||||
self,
|
self,
|
||||||
message: str,
|
message: str,
|
||||||
@ -591,7 +628,7 @@ class AgentService:
|
|||||||
else active_task
|
else active_task
|
||||||
)
|
)
|
||||||
if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
|
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,
|
loaded,
|
||||||
task=task,
|
task=task,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@ -599,7 +636,7 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
|
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,
|
self,
|
||||||
loaded: Any,
|
loaded: Any,
|
||||||
*,
|
*,
|
||||||
@ -607,9 +644,9 @@ class AgentService:
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
comment: str,
|
comment: str,
|
||||||
) -> TaskRecord:
|
) -> 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
|
return task
|
||||||
run_id = next((item for item in reversed(task.run_ids) if item), None)
|
run_id = next((item for item in reversed(task.run_ids) if item), None)
|
||||||
if not run_id:
|
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)
|
existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing.get("feedback_type") != "revise":
|
if existing.get("acceptance_type") != "revise":
|
||||||
return task
|
return task
|
||||||
updated = task
|
updated = task
|
||||||
already_recorded = True
|
already_recorded = True
|
||||||
else:
|
else:
|
||||||
task_service = self._require_loaded(loaded, "task_service")
|
task_service = self._require_loaded(loaded, "task_service")
|
||||||
updated = task_service.add_feedback(
|
updated = task_service.add_acceptance(
|
||||||
task.task_id,
|
task.task_id,
|
||||||
feedback_type="revise",
|
acceptance_type="revise",
|
||||||
comment=comment,
|
comment=comment,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
)
|
)
|
||||||
@ -638,6 +675,7 @@ class AgentService:
|
|||||||
{
|
{
|
||||||
"task_id": updated.task_id,
|
"task_id": updated.task_id,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
|
"acceptance_state": "revise",
|
||||||
"feedback_state": "revise",
|
"feedback_state": "revise",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -648,9 +686,10 @@ class AgentService:
|
|||||||
session_id,
|
session_id,
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
role="system",
|
role="system",
|
||||||
event_type="task_feedback_recorded",
|
event_type="task_acceptance_recorded",
|
||||||
event_payload={
|
event_payload={
|
||||||
"task_id": updated.task_id,
|
"task_id": updated.task_id,
|
||||||
|
"acceptance_type": "revise",
|
||||||
"feedback_type": "revise",
|
"feedback_type": "revise",
|
||||||
"comment": comment,
|
"comment": comment,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
@ -659,12 +698,12 @@ class AgentService:
|
|||||||
content=comment,
|
content=comment,
|
||||||
context_visible=False,
|
context_visible=False,
|
||||||
)
|
)
|
||||||
validation = ValidationResult.from_dict(updated.validation_result)
|
|
||||||
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
run_memory_store = self._require_loaded(loaded, "run_memory_store")
|
||||||
run_memory_store.update_run_record(
|
run_memory_store.update_run_record(
|
||||||
run_id,
|
run_id,
|
||||||
success=False,
|
success=False,
|
||||||
feedback={
|
feedback={
|
||||||
|
"acceptance_type": "revise",
|
||||||
"feedback_type": "revise",
|
"feedback_type": "revise",
|
||||||
"comment": comment,
|
"comment": comment,
|
||||||
"task_status": updated.status,
|
"task_status": updated.status,
|
||||||
@ -673,7 +712,7 @@ class AgentService:
|
|||||||
run_memory_store.update_skill_effects_for_run(
|
run_memory_store.update_skill_effects_for_run(
|
||||||
run_id,
|
run_id,
|
||||||
success=False,
|
success=False,
|
||||||
feedback_score=self._feedback_score_for_learning("revise", validation),
|
feedback_score=self._acceptance_score_for_learning("revise"),
|
||||||
notes=comment.strip() or "revise",
|
notes=comment.strip() or "revise",
|
||||||
)
|
)
|
||||||
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
|
||||||
@ -690,26 +729,21 @@ class AgentService:
|
|||||||
) -> AgentRunResult:
|
) -> AgentRunResult:
|
||||||
loaded = self.create_loop().boot()
|
loaded = self.create_loop().boot()
|
||||||
task_service = self._require_loaded(loaded, "task_service")
|
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")
|
task_execution_planner = self._require_loaded(loaded, "task_execution_planner")
|
||||||
session_manager = self._require_loaded(loaded, "session_manager")
|
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")
|
base_execution_context = kwargs.get("execution_context")
|
||||||
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
|
provider_bundle = kwargs.get("provider_bundle") or self._make_provider_bundle_for_task(loaded, kwargs)
|
||||||
kwargs = dict(kwargs)
|
kwargs = dict(kwargs)
|
||||||
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
|
team_provider_bundle_factory = kwargs.pop("team_provider_bundle_factory", None)
|
||||||
kwargs["provider_bundle"] = provider_bundle
|
kwargs["provider_bundle"] = provider_bundle
|
||||||
|
|
||||||
for attempt_index in (1, 2):
|
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)
|
task_service.start_run(task.task_id, user_message=message, attempt_index=attempt_index)
|
||||||
plan = await task_execution_planner.plan(
|
plan = await task_execution_planner.plan(
|
||||||
task=task,
|
task=task,
|
||||||
user_message=message,
|
user_message=message,
|
||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
latest_validation=latest_validation,
|
|
||||||
provider_bundle=provider_bundle,
|
provider_bundle=provider_bundle,
|
||||||
)
|
)
|
||||||
self._append_task_observation(
|
self._append_task_observation(
|
||||||
@ -793,15 +827,7 @@ class AgentService:
|
|||||||
"allow_candidate_generation": False,
|
"allow_candidate_generation": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if attempt_index == 2 and latest_validation is not None:
|
if team_execution_context:
|
||||||
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)
|
attempt_kwargs["execution_context"] = self._join_context(base_execution_context, team_execution_context)
|
||||||
if plan.is_team and team_execution_context:
|
if plan.is_team and team_execution_context:
|
||||||
attempt_kwargs["include_tools"] = False
|
attempt_kwargs["include_tools"] = False
|
||||||
@ -810,13 +836,11 @@ class AgentService:
|
|||||||
task=task,
|
task=task,
|
||||||
user_message=message,
|
user_message=message,
|
||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
latest_validation=latest_validation,
|
|
||||||
plan=plan,
|
plan=plan,
|
||||||
team_summaries=team_summaries,
|
team_summaries=team_summaries,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await runner(message, **attempt_kwargs)
|
result = await runner(message, **attempt_kwargs)
|
||||||
last_result = result
|
|
||||||
self._append_task_observation(
|
self._append_task_observation(
|
||||||
session_manager,
|
session_manager,
|
||||||
task.session_id,
|
task.session_id,
|
||||||
@ -842,42 +866,7 @@ class AgentService:
|
|||||||
team_result=team_result,
|
team_result=team_result,
|
||||||
)
|
)
|
||||||
evidence_text = render_task_evidence(evidence_packet)
|
evidence_text = render_task_evidence(evidence_packet)
|
||||||
validation = await validation_service.validate_task_result(
|
evidence_debug = {
|
||||||
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": [
|
"evidence_run_ids": [
|
||||||
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
|
item.run_id for item in [evidence_packet.main_run, *evidence_packet.team_runs] if item is not None
|
||||||
],
|
],
|
||||||
@ -893,34 +882,33 @@ class AgentService:
|
|||||||
),
|
),
|
||||||
"evidence_length": len(evidence_text),
|
"evidence_length": len(evidence_text),
|
||||||
}
|
}
|
||||||
retry_scheduled = validation.status == "rejected" and attempt_index == 1
|
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(
|
session_manager.append_message(
|
||||||
result.session_id,
|
result.session_id,
|
||||||
run_id=result.run_id,
|
run_id=result.run_id,
|
||||||
role="system",
|
role="system",
|
||||||
event_type="task_validation_snapshotted",
|
event_type="task_evidence_recorded",
|
||||||
event_payload={
|
event_payload={
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
"attempt_index": attempt_index,
|
"attempt_index": attempt_index,
|
||||||
"validation_result": validation.to_dict(),
|
"evidence_debug": evidence_debug,
|
||||||
"validation_debug": validation_debug,
|
|
||||||
"retry_scheduled": retry_scheduled,
|
|
||||||
},
|
},
|
||||||
content=validation.recommended_revision_prompt or None,
|
content=None,
|
||||||
context_visible=False,
|
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_id = task.task_id
|
||||||
result.task_status = task.status
|
result.task_status = task.status
|
||||||
result.validation_result = validation.to_dict()
|
result.validation_result = None
|
||||||
if not retry_scheduled:
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if last_result is None: # pragma: no cover - defensive
|
|
||||||
raise RuntimeError("Task mode did not produce a run result")
|
|
||||||
return last_result
|
|
||||||
|
|
||||||
async def _run_team_for_task(
|
async def _run_team_for_task(
|
||||||
self,
|
self,
|
||||||
plan: TaskExecutionPlan,
|
plan: TaskExecutionPlan,
|
||||||
@ -986,12 +974,10 @@ class AgentService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _feedback_score_for_learning(feedback_type: str, validation: ValidationResult | None) -> float:
|
def _acceptance_score_for_learning(acceptance_type: str) -> float:
|
||||||
if feedback_type == "satisfied":
|
if acceptance_type == "accept":
|
||||||
if validation is not None:
|
|
||||||
return max(0.0, min(1.0, float(validation.score)))
|
|
||||||
return 1.0
|
return 1.0
|
||||||
if feedback_type == "revise":
|
if acceptance_type == "revise":
|
||||||
return 0.5
|
return 0.5
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@ -1001,12 +987,11 @@ class AgentService:
|
|||||||
task: TaskRecord,
|
task: TaskRecord,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
attempt_index: int,
|
attempt_index: int,
|
||||||
latest_validation: ValidationResult | None = None,
|
|
||||||
plan: TaskExecutionPlan | None = None,
|
plan: TaskExecutionPlan | None = None,
|
||||||
team_summaries: list[str] | None = None,
|
team_summaries: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
phase = f"attempt_{attempt_index}"
|
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}"
|
phase = f"revision_attempt_{attempt_index}"
|
||||||
elif plan is not None and plan.is_team:
|
elif plan is not None and plan.is_team:
|
||||||
phase = f"team_synthesis_attempt_{attempt_index}"
|
phase = f"team_synthesis_attempt_{attempt_index}"
|
||||||
@ -1027,24 +1012,14 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sections.append("Previously activated skills:\nNone")
|
sections.append("Previously activated skills:\nNone")
|
||||||
if latest_validation is not None:
|
if task.feedback:
|
||||||
validation_lines = [
|
history_lines = []
|
||||||
f"accepted: {latest_validation.accepted}",
|
for item in task.feedback[-5:]:
|
||||||
f"score: {latest_validation.score}",
|
kind = item.get("acceptance_type") or item.get("feedback_type")
|
||||||
]
|
comment = item.get("comment") or ""
|
||||||
if latest_validation.issues:
|
run_id = item.get("run_id") or ""
|
||||||
validation_lines.append("issues:\n" + "\n".join(f"- {item}" for item in latest_validation.issues))
|
history_lines.append(f"- {kind} run={run_id}: {comment}".strip())
|
||||||
if latest_validation.missing_requirements:
|
sections.append("Task acceptance history:\n" + "\n".join(history_lines))
|
||||||
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 plan is not None:
|
if plan is not None:
|
||||||
plan_lines = [
|
plan_lines = [
|
||||||
f"mode: {plan.mode}",
|
f"mode: {plan.mode}",
|
||||||
@ -1262,17 +1237,19 @@ class AgentService:
|
|||||||
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
async def handle_inbound_message(self, inbound: InboundMessage) -> OutboundMessage:
|
||||||
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
"""把 bus inbound 映射成标准 runtime 调用,并返回结构化 outbound。"""
|
||||||
|
|
||||||
|
channel_identity = inbound.channel_identity
|
||||||
try:
|
try:
|
||||||
result = await self.submit_direct(
|
result = await self.submit_direct(
|
||||||
inbound.content,
|
inbound.content,
|
||||||
session_id=inbound.session_id,
|
session_id=inbound.session_id,
|
||||||
source=f"gateway:{inbound.channel}",
|
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,
|
title=inbound.title,
|
||||||
execution_context=inbound.execution_context,
|
execution_context=inbound.execution_context,
|
||||||
model=inbound.model,
|
model=inbound.model,
|
||||||
provider_name=inbound.provider_name,
|
provider_name=inbound.provider_name,
|
||||||
embedding_model=inbound.embedding_model,
|
embedding_model=inbound.embedding_model,
|
||||||
|
channel_identity=channel_identity,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return self.build_outbound_error(
|
return self.build_outbound_error(
|
||||||
@ -1308,12 +1285,15 @@ class AgentService:
|
|||||||
finish_reason=result.finish_reason,
|
finish_reason=result.finish_reason,
|
||||||
provider_name=result.provider_name,
|
provider_name=result.provider_name,
|
||||||
model=result.model,
|
model=result.model,
|
||||||
|
content_type=inbound.content_type,
|
||||||
|
channel_identity=inbound.channel_identity,
|
||||||
usage=dict(result.usage),
|
usage=dict(result.usage),
|
||||||
metadata={
|
metadata={
|
||||||
"inbound_metadata": dict(inbound.metadata),
|
"inbound_metadata": dict(inbound.metadata),
|
||||||
"task_id": getattr(result, "task_id", None),
|
"task_id": getattr(result, "task_id", None),
|
||||||
"task_status": getattr(result, "task_status", 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,
|
session_id=inbound.session_id,
|
||||||
content=detail,
|
content=detail,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
|
content_type=inbound.content_type,
|
||||||
|
channel_identity=inbound.channel_identity,
|
||||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -50,10 +50,11 @@ class SessionProcessProjector:
|
|||||||
|
|
||||||
for record in records:
|
for record in records:
|
||||||
payload = dict(record.event_payload or {})
|
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:
|
if not task_id:
|
||||||
continue
|
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}"
|
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
|
||||||
created_at = _timestamp(record.timestamp)
|
created_at = _timestamp(record.timestamp)
|
||||||
root = runs.setdefault(
|
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"
|
strategy = payload.get("strategy") or "single"
|
||||||
node_ids = payload.get("node_ids") or []
|
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["summary"] = payload.get("reason") or ""
|
||||||
root["metadata"] = {
|
root["metadata"] = {
|
||||||
**root.get("metadata", {}),
|
**root.get("metadata", {}),
|
||||||
"plan_mode": payload.get("plan_mode"),
|
"plan_mode": plan_mode,
|
||||||
"strategy": payload.get("strategy"),
|
"strategy": strategy,
|
||||||
"node_ids": node_ids,
|
"node_ids": node_ids,
|
||||||
"skill_queries": payload.get("skill_queries") or [],
|
"skill_queries": payload.get("skill_queries") or [],
|
||||||
"selected_skill_names": payload.get("selected_skill_names") or [],
|
"selected_skill_names": payload.get("selected_skill_names") or [],
|
||||||
@ -92,36 +148,65 @@ class SessionProcessProjector:
|
|||||||
add_event(
|
add_event(
|
||||||
event_id=_event_id(record, "planned"),
|
event_id=_event_id(record, "planned"),
|
||||||
run_id=root_run_id,
|
run_id=root_run_id,
|
||||||
kind="run_started",
|
kind="task_planned",
|
||||||
actor_type="system",
|
actor_type="system",
|
||||||
actor_id="task",
|
actor_id="task",
|
||||||
actor_name="Task Planner",
|
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,
|
created_at=created_at,
|
||||||
status="running",
|
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"}:
|
elif record.event_type in {"task_team_run_completed", "task_team_run_failed"}:
|
||||||
team_success = bool(payload.get("team_success"))
|
team_success = bool(payload.get("team_success"))
|
||||||
root["status"] = "running"
|
root["status"] = "running"
|
||||||
|
team_run_ids = payload.get("team_run_ids") or []
|
||||||
root["metadata"] = {
|
root["metadata"] = {
|
||||||
**root.get("metadata", {}),
|
**root.get("metadata", {}),
|
||||||
"team_success": team_success,
|
"team_success": team_success,
|
||||||
"team_run_ids": payload.get("team_run_ids") or [],
|
"team_run_ids": team_run_ids,
|
||||||
"team_error": payload.get("error"),
|
"team_error": payload.get("error"),
|
||||||
}
|
}
|
||||||
add_event(
|
add_event(
|
||||||
event_id=_event_id(record, "team"),
|
event_id=_event_id(record, "team"),
|
||||||
run_id=root_run_id,
|
run_id=root_run_id,
|
||||||
kind="run_status",
|
kind="agent_team_created",
|
||||||
actor_type="system",
|
actor_type="system",
|
||||||
actor_id="team",
|
actor_id="team",
|
||||||
actor_name="Task Team",
|
actor_name="Task Team",
|
||||||
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
text=payload.get("error") or ("Team completed" if team_success else "Team completed with failed nodes"),
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
status="done" if team_success else "error",
|
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 []
|
node_results = payload.get("node_results") or []
|
||||||
for item in node_results:
|
for item in node_results:
|
||||||
@ -192,20 +277,26 @@ class SessionProcessProjector:
|
|||||||
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
event_id=f"{_event_id(record, 'node')}:{item.get('node_id')}",
|
||||||
run_id=str(node_run_id),
|
run_id=str(node_run_id),
|
||||||
parent_run_id=root_run_id,
|
parent_run_id=root_run_id,
|
||||||
kind="run_finished",
|
kind="agent_finished",
|
||||||
actor_type="agent",
|
actor_type="agent",
|
||||||
actor_id=str(item.get("node_id") or "sub-agent"),
|
actor_id=str(item.get("node_id") or "sub-agent"),
|
||||||
actor_name=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 "")),
|
text=_truncate(str(item.get("output_text") or item.get("error") or "")),
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
status=status,
|
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":
|
elif record.event_type == "task_synthesis_completed":
|
||||||
main_run_id = str(payload.get("main_run_id") or "")
|
main_run_id = str(payload.get("main_run_id") or "")
|
||||||
if main_run_id:
|
if main_run_id:
|
||||||
run_record = run_records.get(main_run_id)
|
run_record = run_records.get(main_run_id)
|
||||||
|
activated_skill_names = _activated_skill_names(run_record)
|
||||||
runs[main_run_id] = {
|
runs[main_run_id] = {
|
||||||
"run_id": main_run_id,
|
"run_id": main_run_id,
|
||||||
"parent_run_id": root_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,
|
"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,
|
"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 ""),
|
"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(
|
add_event(
|
||||||
event_id=_event_id(record, "synthesis"),
|
event_id=_event_id(record, "synthesis"),
|
||||||
run_id=main_run_id,
|
run_id=main_run_id,
|
||||||
@ -235,27 +350,46 @@ class SessionProcessProjector:
|
|||||||
metadata=dict(payload),
|
metadata=dict(payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
elif record.event_type == "task_validation_snapshotted":
|
elif record.event_type == "task_evidence_recorded":
|
||||||
validation = payload.get("validation_result") if isinstance(payload.get("validation_result"), dict) else {}
|
root["status"] = "waiting"
|
||||||
accepted = bool(validation.get("accepted"))
|
root["finished_at"] = None
|
||||||
root["status"] = "done" if accepted or attempt_index == 2 else "waiting"
|
|
||||||
root["finished_at"] = created_at if root["status"] == "done" else None
|
|
||||||
add_event(
|
add_event(
|
||||||
event_id=_event_id(record, "validation"),
|
event_id=_event_id(record, "evidence"),
|
||||||
run_id=record.run_id or root_run_id,
|
run_id=record.run_id or root_run_id,
|
||||||
parent_run_id=root_run_id if record.run_id else None,
|
parent_run_id=root_run_id if record.run_id else None,
|
||||||
kind="run_status",
|
kind="task_result_ready",
|
||||||
actor_type="system",
|
actor_type="system",
|
||||||
actor_id="validator",
|
actor_id="evidence-recorder",
|
||||||
actor_name="Validator",
|
actor_name="Evidence",
|
||||||
text=(
|
text="The task result is ready for user acceptance.",
|
||||||
f"Validation {'passed' if accepted else 'failed'} "
|
|
||||||
f"(score={validation.get('score')})."
|
|
||||||
+ (" Retry scheduled." if payload.get("retry_scheduled") else "")
|
|
||||||
),
|
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
status="done" if accepted else "error",
|
status="done",
|
||||||
metadata=dict(payload),
|
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 {
|
return {
|
||||||
@ -281,3 +415,49 @@ def _truncate(text: str, limit: int = 800) -> str:
|
|||||||
if len(cleaned) <= limit:
|
if len(cleaned) <= limit:
|
||||||
return cleaned
|
return cleaned
|
||||||
return cleaned[: limit - 1] + "..."
|
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
|
||||||
|
|||||||
@ -69,15 +69,24 @@ class SkillLearningService:
|
|||||||
existing_ids.add(candidate.candidate_id)
|
existing_ids.add(candidate.candidate_id)
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
def build_learning_candidates_for_task(self, task_id: str, *, trigger_run_id: str) -> list[SkillLearningCandidate]:
|
def build_learning_candidates_for_task(
|
||||||
"""Build candidates scoped to a single validated and satisfied Task run."""
|
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]
|
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)
|
final_run = next((record for record in runs if record.run_id == final_accepted_run_id), None)
|
||||||
if trigger_run is None or not self._is_confirmed_positive_run(trigger_run):
|
if final_run is None or not self._is_task_accepted_run(final_run):
|
||||||
return []
|
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:
|
if not source_runs:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -100,11 +109,16 @@ class SkillLearningService:
|
|||||||
source_session_ids=source_session_ids,
|
source_session_ids=source_session_ids,
|
||||||
related_skill_names=[],
|
related_skill_names=[],
|
||||||
reason=f"Task {task_id} completed successfully without a published skill; consider extracting reusable guidance.",
|
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",
|
status="open",
|
||||||
priority=1,
|
priority=1,
|
||||||
confidence=0.8,
|
confidence=0.8,
|
||||||
trigger_reason="validation_accepted_and_user_satisfied",
|
trigger_reason="task_accepted",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -137,13 +151,14 @@ class SkillLearningService:
|
|||||||
),
|
),
|
||||||
evidence={
|
evidence={
|
||||||
"task_id": task_id,
|
"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,
|
"skill_version": receipt.skill_version,
|
||||||
},
|
},
|
||||||
status="open",
|
status="open",
|
||||||
priority=1,
|
priority=1,
|
||||||
confidence=0.7,
|
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)
|
groups.setdefault(key, []).append(record)
|
||||||
candidates: list[SkillLearningCandidate] = []
|
candidates: list[SkillLearningCandidate] = []
|
||||||
for theme, runs in groups.items():
|
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:
|
if len(successful) < 2:
|
||||||
continue
|
continue
|
||||||
if any(record.activated_skills for record in successful):
|
if any(record.activated_skills for record in successful):
|
||||||
@ -290,7 +305,7 @@ class SkillLearningService:
|
|||||||
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
def _build_merge_candidates(self) -> list[SkillLearningCandidate]:
|
||||||
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
pair_counts: dict[tuple[str, str], list[RunRecord]] = {}
|
||||||
for record in self.run_store.list_runs():
|
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
|
continue
|
||||||
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
unique = sorted({receipt.skill_name for receipt in record.activated_skills})
|
||||||
for pair in combinations(unique, 2):
|
for pair in combinations(unique, 2):
|
||||||
@ -351,14 +366,15 @@ class SkillLearningService:
|
|||||||
return effects
|
return effects
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_confirmed_positive_run(record: RunRecord) -> bool:
|
def _is_task_accepted_run(record: RunRecord) -> bool:
|
||||||
validation = record.validation_result or {}
|
|
||||||
feedback = record.feedback or {}
|
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 (
|
return (
|
||||||
bool(record.success)
|
bool(record.success)
|
||||||
and bool(record.task_id)
|
and bool(record.task_id)
|
||||||
and validation.get("accepted") is True
|
and acceptance_type == "accept"
|
||||||
and feedback.get("feedback_type") == "satisfied"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from .planner import TaskExecutionPlan, TaskExecutionPlanner
|
|||||||
from .router import MainAgentRouter
|
from .router import MainAgentRouter
|
||||||
from .service import TaskService
|
from .service import TaskService
|
||||||
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
||||||
from .validation import ValidationService
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EvidenceBuilder",
|
"EvidenceBuilder",
|
||||||
@ -24,6 +23,5 @@ __all__ = [
|
|||||||
"ToolEvidence",
|
"ToolEvidence",
|
||||||
"ValidationResult",
|
"ValidationResult",
|
||||||
"ValidationStatus",
|
"ValidationStatus",
|
||||||
"ValidationService",
|
|
||||||
"render_task_evidence",
|
"render_task_evidence",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Models for internal task tracking and validation."""
|
"""Models for internal task tracking and user acceptance."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -9,7 +9,12 @@ from typing import Any, Literal
|
|||||||
ValidationStatus = Literal["accepted", "rejected", "insufficient_evidence", "validator_error"]
|
ValidationStatus = Literal["accepted", "rejected", "insufficient_evidence", "validator_error"]
|
||||||
|
|
||||||
VALIDATION_STATUSES = {"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)
|
@dataclass(slots=True)
|
||||||
@ -113,11 +118,11 @@ class TaskRecord:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_execution_active(self) -> bool:
|
def is_execution_active(self) -> bool:
|
||||||
return self.status in {"running", "validating"}
|
return self.status == "running"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def requires_user_action(self) -> bool:
|
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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@ -137,6 +142,7 @@ class TaskRecord:
|
|||||||
"satisfaction": self.satisfaction,
|
"satisfaction": self.satisfaction,
|
||||||
"run_ids": list(self.run_ids),
|
"run_ids": list(self.run_ids),
|
||||||
"skill_names": list(self.skill_names),
|
"skill_names": list(self.skill_names),
|
||||||
|
"acceptance": list(self.feedback),
|
||||||
"feedback": list(self.feedback),
|
"feedback": list(self.feedback),
|
||||||
"validation_result": self.validation_result,
|
"validation_result": self.validation_result,
|
||||||
"metadata": dict(self.metadata),
|
"metadata": dict(self.metadata),
|
||||||
@ -152,7 +158,7 @@ class TaskRecord:
|
|||||||
goal=str(payload.get("goal") or payload.get("description") or ""),
|
goal=str(payload.get("goal") or payload.get("description") or ""),
|
||||||
constraints=[str(item) for item in payload.get("constraints") or []],
|
constraints=[str(item) for item in payload.get("constraints") or []],
|
||||||
priority=int(payload.get("priority", 0) or 0),
|
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"),
|
creator=str(payload.get("creator") or "main-agent"),
|
||||||
created_at=str(payload.get("created_at") or ""),
|
created_at=str(payload.get("created_at") or ""),
|
||||||
updated_at=str(payload.get("updated_at") or ""),
|
updated_at=str(payload.get("updated_at") or ""),
|
||||||
@ -161,7 +167,11 @@ class TaskRecord:
|
|||||||
satisfaction=_optional_float(payload.get("satisfaction")),
|
satisfaction=_optional_float(payload.get("satisfaction")),
|
||||||
run_ids=[str(item) for item in payload.get("run_ids") or []],
|
run_ids=[str(item) for item in payload.get("run_ids") or []],
|
||||||
skill_names=[str(item) for item in payload.get("skill_names") 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,
|
validation_result=dict(payload["validation_result"]) if isinstance(payload.get("validation_result"), dict) else None,
|
||||||
metadata=dict(payload.get("metadata") or {}),
|
metadata=dict(payload.get("metadata") or {}),
|
||||||
)
|
)
|
||||||
@ -226,3 +236,13 @@ def _optional_float(value: Any) -> float | None:
|
|||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return None
|
return None
|
||||||
return float(value)
|
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
|
||||||
|
|||||||
@ -10,7 +10,7 @@ from typing import Any, Literal
|
|||||||
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
|
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
|
||||||
from beaver.engine.providers import ProviderBundle
|
from beaver.engine.providers import ProviderBundle
|
||||||
|
|
||||||
from .models import TaskRecord, ValidationResult
|
from .models import TaskRecord
|
||||||
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
from .skill_resolver import SkillResolutionReport, TaskSkillResolver
|
||||||
|
|
||||||
|
|
||||||
@ -76,7 +76,6 @@ class TaskExecutionPlanner:
|
|||||||
task: TaskRecord,
|
task: TaskRecord,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
attempt_index: int,
|
attempt_index: int,
|
||||||
latest_validation: ValidationResult | None = None,
|
|
||||||
provider_bundle: ProviderBundle | None = None,
|
provider_bundle: ProviderBundle | None = None,
|
||||||
timeout_seconds: float = 30.0,
|
timeout_seconds: float = 30.0,
|
||||||
) -> TaskExecutionPlan:
|
) -> TaskExecutionPlan:
|
||||||
@ -105,7 +104,6 @@ class TaskExecutionPlanner:
|
|||||||
task=task,
|
task=task,
|
||||||
user_message=user_message,
|
user_message=user_message,
|
||||||
attempt_index=attempt_index,
|
attempt_index=attempt_index,
|
||||||
latest_validation=latest_validation,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -230,14 +228,10 @@ class TaskExecutionPlanner:
|
|||||||
task: TaskRecord,
|
task: TaskRecord,
|
||||||
user_message: str,
|
user_message: str,
|
||||||
attempt_index: int,
|
attempt_index: int,
|
||||||
latest_validation: ValidationResult | None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
validation_note = ""
|
history_note = ""
|
||||||
if latest_validation is not None:
|
if task.feedback:
|
||||||
validation_note = (
|
history_note = "\nRelevant task history:\n" + json.dumps(task.feedback[-5:], ensure_ascii=False)
|
||||||
"\nPrevious validation issues:\n"
|
|
||||||
+ json.dumps(latest_validation.to_dict(), ensure_ascii=False)
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
"Decide execution mode for this internal Task attempt.\n"
|
"Decide execution mode for this internal Task attempt.\n"
|
||||||
"Use mode=team only when independent research, review, implementation slices, or staged checks "
|
"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"Task goal:\n{task.goal}\n\n"
|
||||||
f"Current user request:\n{user_message}\n\n"
|
f"Current user request:\n{user_message}\n\n"
|
||||||
f"Attempt index: {attempt_index}\n"
|
f"Attempt index: {attempt_index}\n"
|
||||||
f"{validation_note}"
|
f"{history_note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .models import TaskEvent, TaskRecord, ValidationResult
|
from .models import TaskEvent, TaskRecord
|
||||||
from .store import TaskStore
|
from .store import TaskStore
|
||||||
|
|
||||||
|
|
||||||
@ -105,38 +105,70 @@ class TaskService:
|
|||||||
for name in skill_names or []:
|
for name in skill_names or []:
|
||||||
if name not in task.skill_names:
|
if name not in task.skill_names:
|
||||||
task.skill_names.append(name)
|
task.skill_names.append(name)
|
||||||
|
task.status = "awaiting_acceptance"
|
||||||
task.updated_at = self._now()
|
task.updated_at = self._now()
|
||||||
self.store.upsert_task(task)
|
self.store.upsert_task(task)
|
||||||
self._event(task, "run_completed", run_id=run_id, payload={"skill_names": skill_names or []})
|
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
|
return task
|
||||||
|
|
||||||
def record_validation(
|
def add_acceptance(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
run_id: str,
|
|
||||||
validation: ValidationResult,
|
|
||||||
*,
|
*,
|
||||||
final_attempt: bool = True,
|
acceptance_type: str,
|
||||||
has_usable_answer: bool = True,
|
comment: str | None = None,
|
||||||
|
run_id: str | None = None,
|
||||||
) -> TaskRecord:
|
) -> TaskRecord:
|
||||||
task = self._require(task_id)
|
task = self._require(task_id)
|
||||||
now = self._now()
|
now = self._now()
|
||||||
if validation.status == "accepted":
|
normalized = normalize_acceptance_type(acceptance_type)
|
||||||
task.status = "awaiting_feedback"
|
matching_acceptance = any(
|
||||||
elif validation.status in {"insufficient_evidence", "validator_error"}:
|
item.get("run_id") == run_id and item.get("acceptance_type") == normalized
|
||||||
task.status = "needs_review"
|
for item in task.feedback
|
||||||
elif validation.status == "rejected" and not final_attempt:
|
)
|
||||||
|
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"
|
task.status = "needs_revision"
|
||||||
elif validation.status == "rejected" and has_usable_answer:
|
elif normalized == "abandon":
|
||||||
task.status = "needs_review"
|
task.status = "abandoned"
|
||||||
else:
|
|
||||||
task.status = "failed"
|
|
||||||
task.closed_at = now
|
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.updated_at = now
|
||||||
task.validation_result = validation.to_dict()
|
|
||||||
self.store.upsert_task(task)
|
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
|
return task
|
||||||
|
|
||||||
def add_feedback(
|
def add_feedback(
|
||||||
@ -147,52 +179,12 @@ class TaskService:
|
|||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
run_id: str | None = None,
|
run_id: str | None = None,
|
||||||
) -> TaskRecord:
|
) -> TaskRecord:
|
||||||
task = self._require(task_id)
|
return self.add_acceptance(
|
||||||
now = self._now()
|
task_id,
|
||||||
matching_feedback = any(
|
acceptance_type=feedback_type,
|
||||||
item.get("run_id") == run_id and item.get("feedback_type") == feedback_type
|
comment=comment,
|
||||||
for item in task.feedback
|
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:
|
def close_task(self, task_id: str, *, reason: str = "closed") -> TaskRecord:
|
||||||
task = self._require(task_id)
|
task = self._require(task_id)
|
||||||
@ -267,3 +259,12 @@ def short_task_title(text: str) -> str:
|
|||||||
if len(words) <= 4:
|
if len(words) <= 4:
|
||||||
return cleaned[:40]
|
return cleaned[:40]
|
||||||
return " ".join(words[:4])[: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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -51,7 +51,7 @@ class WebFetchTool:
|
|||||||
try:
|
try:
|
||||||
safe_url = _safe_url(url)
|
safe_url = _safe_url(url)
|
||||||
limit = max(1000, min(int(max_chars or 12000), 50000))
|
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(
|
response = await client.get(
|
||||||
safe_url,
|
safe_url,
|
||||||
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
|
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
|
||||||
@ -96,7 +96,7 @@ class WebSearchTool:
|
|||||||
raise ValueError("query is required")
|
raise ValueError("query is required")
|
||||||
bounded = max(1, min(int(limit or 5), 10))
|
bounded = max(1, min(int(limit or 5), 10))
|
||||||
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
|
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 = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
html = response.text
|
html = response.text
|
||||||
|
|||||||
@ -22,6 +22,23 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.0,<10.0.0",
|
"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]
|
[project.scripts]
|
||||||
beaver = "beaver.interfaces.cli.main:main"
|
beaver = "beaver.interfaces.cli.main:main"
|
||||||
|
|||||||
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal file
47
app-instance/backend/tests/unit/test_agent_loop.py
Normal 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())
|
||||||
@ -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"}]
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal file
414
app-instance/backend/tests/unit/test_channel_runtime.py
Normal 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
|
||||||
@ -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())
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from beaver.engine import AgentLoop, EngineLoader
|
from beaver.engine import AgentLoop, EngineLoader
|
||||||
from beaver.engine.providers import make_provider_bundle
|
from beaver.engine.providers import make_provider_bundle
|
||||||
from beaver.engine.providers.litellm import LiteLLMProvider
|
from beaver.engine.providers.litellm import LiteLLMProvider
|
||||||
from beaver.foundation.config import load_config
|
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
|
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"}
|
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:
|
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@ -161,6 +202,201 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
|
|||||||
service.close()
|
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:
|
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
|
||||||
bundle = make_provider_bundle(
|
bundle = make_provider_bundle(
|
||||||
model="qwen-plus",
|
model="qwen-plus",
|
||||||
|
|||||||
@ -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
|
||||||
28
app-instance/backend/tests/unit/test_context_builder.py
Normal file
28
app-instance/backend/tests/unit/test_context_builder.py
Normal 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": "今天几号?"}
|
||||||
@ -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()
|
||||||
@ -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())
|
||||||
@ -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()
|
||||||
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal file
154
app-instance/backend/tests/unit/test_feishu_channel_adapter.py
Normal 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())
|
||||||
@ -2,9 +2,10 @@ import asyncio
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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.channels import ChannelManager, MemoryChannelAdapter
|
||||||
from beaver.interfaces.gateway.main import run_gateway
|
from beaver.interfaces.gateway.main import run_gateway
|
||||||
|
from beaver.interfaces.channels.runtime import ChannelRuntime
|
||||||
from beaver.services.agent_service import AgentService
|
from beaver.services.agent_service import AgentService
|
||||||
|
|
||||||
|
|
||||||
@ -18,8 +19,8 @@ class FakeResult:
|
|||||||
model: str | None = "fake-model"
|
model: str | None = "fake-model"
|
||||||
usage: dict[str, Any] = field(default_factory=dict)
|
usage: dict[str, Any] = field(default_factory=dict)
|
||||||
task_id: str | None = "task-1"
|
task_id: str | None = "task-1"
|
||||||
task_status: str | None = "awaiting_feedback"
|
task_status: str | None = "awaiting_acceptance"
|
||||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
validation_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class FakeService:
|
class FakeService:
|
||||||
@ -52,22 +53,15 @@ class InvalidService:
|
|||||||
is_running = True
|
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:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus)
|
runtime = ChannelRuntime(service=FakeService(), bus=bus, channels={}, workspace=tmp_path)
|
||||||
stop_event = asyncio.Event()
|
channel = MemoryChannelAdapter(runtime)
|
||||||
task = asyncio.create_task(
|
runtime.manager.register(channel)
|
||||||
run_gateway(
|
await runtime.start()
|
||||||
service=FakeService(),
|
|
||||||
manage_service_lifecycle=False,
|
|
||||||
bus=bus,
|
|
||||||
channels=[channel],
|
|
||||||
stop_event=stop_event,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel.publish_text("hello", session_id="s1")
|
await channel.publish_text("hello", peer_id="s1", message_id="m1")
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if channel.sent_messages:
|
if channel.sent_messages:
|
||||||
break
|
break
|
||||||
@ -76,37 +70,73 @@ def test_gateway_routes_memory_channel_roundtrip() -> None:
|
|||||||
assert channel.sent_messages
|
assert channel.sent_messages
|
||||||
message = channel.sent_messages[0]
|
message = channel.sent_messages[0]
|
||||||
assert message.content == "echo:hello"
|
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.finish_reason == "stop"
|
||||||
assert message.metadata["task_id"] == "task-1"
|
assert message.metadata["task_id"] == "task-1"
|
||||||
assert message.metadata["task_status"] == "awaiting_feedback"
|
assert message.metadata["task_status"] == "awaiting_acceptance"
|
||||||
assert message.metadata["validation_result"] == {"accepted": True}
|
assert message.metadata["evidence_status"] == "recorded"
|
||||||
|
assert message.metadata["validation_result"] is None
|
||||||
|
|
||||||
stop_event.set()
|
await runtime.stop()
|
||||||
await asyncio.wait_for(task, timeout=2)
|
|
||||||
|
|
||||||
asyncio.run(run())
|
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:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
channel = MemoryChannelAdapter(bus)
|
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",
|
||||||
|
)
|
||||||
|
)
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
task = asyncio.create_task(
|
|
||||||
run_gateway(
|
|
||||||
service=SlowService(),
|
|
||||||
manage_service_lifecycle=False,
|
|
||||||
bus=bus,
|
|
||||||
channels=[channel],
|
|
||||||
stop_event=stop_event,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel.publish_text("slow", session_id="s1")
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
stop_event.set()
|
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
|
||||||
assert channel.sent_messages[0].finish_reason == "cancelled"
|
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:
|
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
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:
|
try:
|
||||||
await run_gateway(
|
await run_gateway(
|
||||||
service=FakeService(),
|
service=FakeService(),
|
||||||
manage_service_lifecycle=False,
|
manage_service_lifecycle=False,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
channel_manager=ChannelManager(bus),
|
channel_manager=ChannelManager(bus),
|
||||||
channels=[MemoryChannelAdapter(bus)],
|
channels=[CaptureChannel()],
|
||||||
stop_event=asyncio.Event(),
|
stop_event=asyncio.Event(),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@ -211,10 +255,16 @@ def test_channel_manager_keeps_unknown_channel_outbound_undeliverable() -> None:
|
|||||||
asyncio.run(run())
|
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:
|
async def run() -> None:
|
||||||
bus = MessageBus()
|
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(
|
inbound = await channel.publish_external_text(
|
||||||
"hello",
|
"hello",
|
||||||
chat_id="chat-1",
|
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()
|
queued = await bus.consume_inbound()
|
||||||
assert queued is inbound
|
assert queued is inbound
|
||||||
assert queued.channel == "telegram"
|
assert queued.channel == "telegram-main"
|
||||||
assert queued.session_id == "telegram:chat-1"
|
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["chat_id"] == "chat-1"
|
||||||
assert queued.metadata["message_id"] == "message-1"
|
assert queued.metadata["message_id"] == "message-1"
|
||||||
assert queued.metadata["raw_channel_payload"] == {"platform": "telegram", "text": "hello"}
|
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:
|
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
|
||||||
class StartedChannel:
|
class StartedChannel:
|
||||||
name = "started"
|
channel_id = "started"
|
||||||
|
kind = "memory"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus) -> None:
|
def __init__(self, bus: MessageBus) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@ -251,7 +305,9 @@ def test_channel_manager_start_cancellation_rolls_back_started_channels() -> Non
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class BlockingChannel:
|
class BlockingChannel:
|
||||||
name = "blocking"
|
channel_id = "blocking"
|
||||||
|
kind = "memory"
|
||||||
|
mode = "webhook"
|
||||||
|
|
||||||
def __init__(self, bus: MessageBus) -> None:
|
def __init__(self, bus: MessageBus) -> None:
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
|
|||||||
@ -6,6 +6,34 @@ from beaver.interfaces.web.app import create_app
|
|||||||
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
|
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:
|
def test_agent_loop_boots(tmp_path) -> None:
|
||||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
|
||||||
loaded = loop.boot()
|
loaded = loop.boot()
|
||||||
@ -32,10 +60,14 @@ def test_message_bus_imports() -> None:
|
|||||||
|
|
||||||
def test_channel_imports() -> None:
|
def test_channel_imports() -> None:
|
||||||
bus = MessageBus()
|
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 = ChannelManager(bus)
|
||||||
manager.register(channel)
|
manager.register(channel)
|
||||||
assert manager.channels["memory"] is channel
|
assert manager.channels["memory-dev"] is channel
|
||||||
|
|
||||||
|
|
||||||
def test_web_schema_imports() -> None:
|
def test_web_schema_imports() -> None:
|
||||||
|
|||||||
@ -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()
|
||||||
@ -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"
|
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:
|
def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
|
|
||||||
|
|||||||
@ -79,7 +79,7 @@ def _task() -> TaskRecord:
|
|||||||
goal="实现任务连续性",
|
goal="实现任务连续性",
|
||||||
constraints=[],
|
constraints=[],
|
||||||
priority=0,
|
priority=0,
|
||||||
status="awaiting_feedback",
|
status="awaiting_acceptance",
|
||||||
creator="test",
|
creator="test",
|
||||||
created_at="now",
|
created_at="now",
|
||||||
updated_at="now",
|
updated_at="now",
|
||||||
|
|||||||
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal file
64
app-instance/backend/tests/unit/test_max_tokens_defaults.py
Normal 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
|
||||||
@ -35,6 +35,7 @@ class StubProvider(LLMProvider):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
thinking_enabled: bool | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
if not self._responses:
|
if not self._responses:
|
||||||
raise AssertionError("No stubbed provider responses left")
|
raise AssertionError("No stubbed provider responses left")
|
||||||
@ -47,11 +48,22 @@ class StubProvider(LLMProvider):
|
|||||||
class StubSkillAssembler:
|
class StubSkillAssembler:
|
||||||
def __init__(self, activated_skills: list[SkillContext]) -> None:
|
def __init__(self, activated_skills: list[SkillContext]) -> None:
|
||||||
self.activated_skills = activated_skills
|
self.activated_skills = activated_skills
|
||||||
|
self.calls: list[dict] = []
|
||||||
|
|
||||||
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
async def assemble(self, **kwargs) -> SkillAssemblyResult:
|
||||||
|
self.calls.append(kwargs)
|
||||||
return SkillAssemblyResult(activated_skills=list(self.activated_skills))
|
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:
|
def _tool_call(*, name: str = "echo", arguments: dict | None = None, call_id: str = "call-1") -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id=call_id,
|
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
|
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:
|
def test_agent_loop_records_max_tool_iterations_as_failed_skill_effect(tmp_path: Path) -> None:
|
||||||
skill = SkillContext(
|
skill = SkillContext(
|
||||||
name="docker-debug",
|
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
|
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:
|
def test_llm_request_snapshot_defaults_to_compact_payload(tmp_path: Path) -> None:
|
||||||
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path, skill_assembler=StubSkillAssembler([])))
|
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path, skill_assembler=StubSkillAssembler([])))
|
||||||
bundle = ProviderBundle(
|
bundle = ProviderBundle(
|
||||||
|
|||||||
@ -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]"
|
||||||
@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
from beaver.engine.session import SessionManager
|
from beaver.engine.session import SessionManager
|
||||||
from beaver.memory.runs import RunMemoryStore, RunRecord
|
from beaver.memory.runs import RunMemoryStore, RunRecord
|
||||||
from beaver.services.process_service import SessionProcessProjector
|
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:
|
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",
|
"web:test",
|
||||||
run_id="main-run",
|
run_id="main-run",
|
||||||
role="system",
|
role="system",
|
||||||
event_type="task_validation_snapshotted",
|
event_type="task_evidence_recorded",
|
||||||
event_payload={
|
event_payload={
|
||||||
"task_id": "task-1",
|
"task_id": "task-1",
|
||||||
"attempt_index": 1,
|
"attempt_index": 1,
|
||||||
"validation_result": {"accepted": True, "score": 0.9},
|
"evidence_status": "recorded",
|
||||||
"retry_scheduled": False,
|
},
|
||||||
|
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,
|
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"]["selected_skill_names"] == ["research-workflow"]
|
||||||
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
assert sub_run["metadata"]["skill_query"] == "research workflow"
|
||||||
assert sub_run["metadata"]["ephemeral_guidance_id"] is None
|
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"])
|
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:
|
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
|
||||||
session = SessionManager(tmp_path)
|
session = SessionManager(tmp_path)
|
||||||
|
|||||||
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal file
143
app-instance/backend/tests/unit/test_qqbot_channel_adapter.py
Normal 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())
|
||||||
@ -4,23 +4,17 @@ import asyncio
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from beaver.coordinator import AgentDescriptor, ExecutionGraph, ExecutionNode
|
|
||||||
from beaver.engine import EngineLoader
|
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.base import LLMProvider, LLMResponse
|
||||||
from beaver.engine.providers.factory import ProviderBundle
|
from beaver.engine.providers.factory import ProviderBundle
|
||||||
from beaver.services.agent_service import AgentService
|
from beaver.services.agent_service import AgentService
|
||||||
from beaver.skills.assembler import SkillAssemblyResult
|
from beaver.tasks import TaskExecutionPlan, TaskService
|
||||||
from beaver.tasks import TaskExecutionPlan, TaskRecord, TaskService, ValidationResult, ValidationService
|
|
||||||
|
|
||||||
|
|
||||||
class StubProvider(LLMProvider):
|
class StubProvider(LLMProvider):
|
||||||
def __init__(self, responses: list[LLMResponse]) -> None:
|
def __init__(self, responses: list[LLMResponse]) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._responses = list(responses)
|
self._responses = list(responses)
|
||||||
self.calls: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
@ -30,7 +24,6 @@ class StubProvider(LLMProvider):
|
|||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
self.calls.append({"messages": messages, "tools": tools, "model": model})
|
|
||||||
if not self._responses:
|
if not self._responses:
|
||||||
raise AssertionError("No stubbed provider responses left")
|
raise AssertionError("No stubbed provider responses left")
|
||||||
return self._responses.pop(0)
|
return self._responses.pop(0)
|
||||||
@ -39,30 +32,9 @@ class StubProvider(LLMProvider):
|
|||||||
return "stub-model"
|
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:
|
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:
|
async def plan(self, **kwargs) -> TaskExecutionPlan:
|
||||||
self.calls.append(kwargs)
|
return TaskExecutionPlan.single("test-single")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLearningCandidate:
|
class FakeLearningCandidate:
|
||||||
@ -70,15 +42,6 @@ class FakeLearningCandidate:
|
|||||||
return {"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
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:
|
def _route_response(action: str = "new_task", short_title: str = "Test task") -> LLMResponse:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=f'{{"action":"{action}","reason":"test route","short_title":"{short_title}"}}',
|
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:
|
def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> None:
|
||||||
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:
|
|
||||||
service = AgentService(
|
service = AgentService(
|
||||||
loader=EngineLoader(
|
loader=EngineLoader(
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
task_execution_planner=_single_planner(),
|
task_execution_planner=StubTaskExecutionPlanner(),
|
||||||
validation_service=StubValidationService([]),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
service.process_direct(
|
service.process_direct(
|
||||||
"hello?",
|
"draft release notes",
|
||||||
session_id="web:simple",
|
session_id="web:test",
|
||||||
provider_bundle=_bundle("hi", route_action="simple_chat"),
|
provider_bundle=_bundle("Done"),
|
||||||
)
|
|
||||||
)
|
|
||||||
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")]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(
|
task_service = service.create_loop().boot().task_service
|
||||||
service.process_direct(
|
assert task_service is not None
|
||||||
"implement the new report workflow",
|
task = task_service.get_task(result.task_id or "")
|
||||||
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
|
|
||||||
assert task is not None
|
assert task is not None
|
||||||
assert task.status == "awaiting_feedback"
|
assert task.status == "awaiting_acceptance"
|
||||||
assert any(event.event_type == "task_validation_snapshotted" for event in events)
|
assert task.validation_result is None
|
||||||
assert run_record.task_id == result.task_id
|
assert result.validation_result is None
|
||||||
assert run_record.validation_result["accepted"] is True
|
|
||||||
assert skill_effects.event_payload["candidate_generation_allowed"] is False
|
event_types = [event.event_type for event in task_service.list_events(task.task_id)]
|
||||||
assert skill_effects.event_payload["learning_candidates"] == []
|
assert "evidence_recorded" in event_types
|
||||||
assert task.metadata["short_title"] == "Test task"
|
assert "validated" not in event_types
|
||||||
|
|
||||||
|
|
||||||
def test_task_mode_uses_task_aware_skill_selection_context(tmp_path: Path) -> None:
|
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
|
||||||
skill_assembler = RecordingSkillAssembler()
|
|
||||||
service = AgentService(
|
service = AgentService(
|
||||||
loader=EngineLoader(
|
loader=EngineLoader(
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
task_execution_planner=_single_planner(),
|
task_execution_planner=StubTaskExecutionPlanner(),
|
||||||
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")]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
service.process_direct(
|
service.process_direct(
|
||||||
"implement feedback handling",
|
"write implementation plan",
|
||||||
session_id="web:feedback",
|
session_id="web:acceptance",
|
||||||
provider_bundle=_bundle("done"),
|
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]:
|
loaded = service.create_loop().boot()
|
||||||
learning_calls.append((task_id, trigger_run_id))
|
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()]
|
return [FakeLearningCandidate()]
|
||||||
|
|
||||||
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
|
loaded.skill_learning_service.build_learning_candidates_for_task = build_learning_candidates_for_task
|
||||||
|
|
||||||
feedback = asyncio.run(
|
response = asyncio.run(
|
||||||
service.submit_feedback(
|
service.submit_acceptance(
|
||||||
session_id=result.session_id,
|
session_id="web:acceptance",
|
||||||
run_id=result.run_id,
|
run_id=result.run_id,
|
||||||
feedback_type="satisfied",
|
acceptance_type="accept",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert feedback["task_status"] == "closed"
|
assert response["task_status"] == "closed"
|
||||||
assert feedback["learning_candidates"] == [
|
assert response["acceptance_type"] == "accept"
|
||||||
|
assert response["learning_candidates"] == [
|
||||||
{"candidate_id": "candidate-1", "kind": "new_skill", "status": "open"}
|
{"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(
|
task_service = loaded.task_service
|
||||||
loader=EngineLoader(
|
assert task_service is not None
|
||||||
workspace=tmp_path / "abandon",
|
task = task_service.get_task(result.task_id or "")
|
||||||
task_execution_planner=_single_planner(),
|
assert task is not None
|
||||||
validation_service=StubValidationService(
|
assert task.metadata["final_accepted_run_id"] == result.run_id
|
||||||
[
|
|
||||||
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 == []
|
|
||||||
|
|
||||||
|
|
||||||
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(
|
service = AgentService(
|
||||||
loader=EngineLoader(
|
loader=EngineLoader(
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
task_execution_planner=_single_planner(),
|
task_execution_planner=StubTaskExecutionPlanner(),
|
||||||
validation_service=StubValidationService(
|
|
||||||
[ValidationResult(passed=True, score=0.9, validator="test")]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
service.process_direct(
|
service.process_direct(
|
||||||
"implement feedback projection",
|
"summarize notes",
|
||||||
session_id="web:feedback-projection",
|
session_id="web:revise",
|
||||||
provider_bundle=_bundle("done"),
|
provider_bundle=_bundle("Summary"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
loaded = service.create_loop().boot()
|
|
||||||
|
|
||||||
first = asyncio.run(
|
response = asyncio.run(
|
||||||
service.submit_feedback(
|
service.submit_acceptance(
|
||||||
session_id=result.session_id,
|
session_id="web:revise",
|
||||||
run_id=result.run_id,
|
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(
|
service.submit_feedback(
|
||||||
session_id=result.session_id,
|
session_id="web:legacy",
|
||||||
run_id=result.run_id,
|
run_id=result.run_id,
|
||||||
feedback_type="satisfied",
|
feedback_type="satisfied",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
feedback_events = [
|
assert response["acceptance_type"] == "accept"
|
||||||
event
|
assert response["feedback_type"] == "satisfied"
|
||||||
for event in loaded.session_manager.get_run_event_records(result.session_id, result.run_id)
|
assert response["task_status"] == "closed"
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_task_mode_team_plan_runs_subagent_then_main_synthesis(tmp_path: Path) -> None:
|
def test_task_service_maps_legacy_status_and_feedback(tmp_path: Path) -> None:
|
||||||
main_provider = StubProvider(
|
service = TaskService(tmp_path)
|
||||||
[
|
task = service.create_task(session_id="s", description="legacy")
|
||||||
LLMResponse(content="final synthesized answer", finish_reason="stop", provider_name="stub", model="stub-model")
|
task.status = "awaiting_feedback"
|
||||||
]
|
task.feedback.append({"feedback_type": "satisfied", "run_id": "run-1"})
|
||||||
)
|
service.store.upsert_task(task)
|
||||||
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")]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(
|
loaded = service.get_task(task.task_id)
|
||||||
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)
|
|
||||||
|
|
||||||
assert result.output_text == "final synthesized answer"
|
assert loaded is not None
|
||||||
assert task is not None
|
assert loaded.status == "awaiting_acceptance"
|
||||||
assert len(task.run_ids) == 2
|
assert loaded.feedback[0]["acceptance_type"] == "accept"
|
||||||
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
|
|
||||||
|
|||||||
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal file
141
app-instance/backend/tests/unit/test_telegram_channel_adapter.py
Normal 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())
|
||||||
@ -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())
|
||||||
@ -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()
|
||||||
44
app-instance/backend/tests/unit/test_web_tools.py
Normal file
44
app-instance/backend/tests/unit/test_web_tools.py
Normal 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]
|
||||||
@ -20,8 +20,8 @@ class StubRunResult:
|
|||||||
model: str | None = "stub-model"
|
model: str | None = "stub-model"
|
||||||
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
|
usage: dict[str, Any] = field(default_factory=lambda: {"total_tokens": 3})
|
||||||
task_id: str | None = "task-1"
|
task_id: str | None = "task-1"
|
||||||
task_status: str | None = "awaiting_feedback"
|
task_status: str | None = "awaiting_acceptance"
|
||||||
validation_result: dict[str, Any] | None = field(default_factory=lambda: {"accepted": True})
|
validation_result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class StubAgentService(AgentService):
|
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["session_id"] == "web:alpha"
|
||||||
assert message["run_id"] == "run-1"
|
assert message["run_id"] == "run-1"
|
||||||
assert message["task_id"] == "task-1"
|
assert message["task_id"] == "task-1"
|
||||||
assert message["task_status"] == "awaiting_feedback"
|
assert message["task_status"] == "awaiting_acceptance"
|
||||||
assert message["validation_result"] == {"accepted": True}
|
assert message["evidence_status"] == "recorded"
|
||||||
assert message["validation_status"] == "passed"
|
assert message["validation_result"] is None
|
||||||
|
assert "validation_status" not in message
|
||||||
assert message["metadata"]["input_metadata"] == {
|
assert message["metadata"]["input_metadata"] == {
|
||||||
"source": "test",
|
"source": "test",
|
||||||
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
|
"attachments": [{"file_id": "file-1", "name": "a.txt"}],
|
||||||
|
|||||||
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal file
129
app-instance/backend/tests/unit/test_weixin_channel_adapter.py
Normal 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())
|
||||||
185
app-instance/backend/uv.lock
generated
185
app-instance/backend/uv.lock
generated
@ -252,27 +252,51 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
channels = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "lark-oapi" },
|
||||||
|
{ name = "python-telegram-bot" },
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
]
|
]
|
||||||
|
feishu = [
|
||||||
|
{ name = "lark-oapi" },
|
||||||
|
]
|
||||||
|
qqbot = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
]
|
||||||
|
telegram = [
|
||||||
|
{ name = "python-telegram-bot" },
|
||||||
|
]
|
||||||
|
weixin = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
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 = "anthropic", specifier = ">=0.51.0,<1.0.0" },
|
||||||
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
{ name = "croniter", specifier = ">=6.0.0,<7.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||||
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
{ name = "fastmcp", specifier = ">=3.0.0,<4.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||||
{ name = "json-repair", specifier = ">=0.39.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 = "litellm", specifier = ">=1.79.0,<2.0.0" },
|
||||||
{ name = "openai", 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 = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.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-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 = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.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]]
|
[[package]]
|
||||||
name = "cachetools"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "litellm"
|
name = "litellm"
|
||||||
version = "1.80.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pywin32"
|
name = "pywin32"
|
||||||
version = "311"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "15.0.0"
|
version = "15.0.0"
|
||||||
@ -2687,61 +2781,44 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "16.0"
|
version = "15.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||||
{ 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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -37,6 +37,8 @@ INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
|
|||||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||||
NETWORK_NAME="${NETWORK_NAME:-}"
|
NETWORK_NAME="${NETWORK_NAME:-}"
|
||||||
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
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
|
FORCE_BUILD=0
|
||||||
REPLACE=0
|
REPLACE=0
|
||||||
|
|
||||||
@ -78,6 +80,9 @@ Optional:
|
|||||||
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
||||||
--network <name> Optional docker network name.
|
--network <name> Optional docker network name.
|
||||||
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
--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.
|
--build Force rebuild image before running.
|
||||||
--replace Remove existing container with same name before running.
|
--replace Remove existing container with same name before running.
|
||||||
--help Show this help.
|
--help Show this help.
|
||||||
@ -225,6 +230,69 @@ data = {
|
|||||||
"name": os.environ["BACKEND_NAME"].strip(),
|
"name": os.environ["BACKEND_NAME"].strip(),
|
||||||
"publicBaseUrl": os.environ["PUBLIC_URL"].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")
|
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
|
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() {
|
render_runtime_env_file() {
|
||||||
local target_path="$1"
|
local target_path="$1"
|
||||||
|
|
||||||
@ -428,6 +556,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
HOST_BIND_IP="${2:-}"
|
HOST_BIND_IP="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--initial-skills-dir)
|
||||||
|
INITIAL_SKILLS_DIR="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-initial-skills)
|
||||||
|
SEED_INITIAL_SKILLS=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--build)
|
--build)
|
||||||
FORCE_BUILD=1
|
FORCE_BUILD=1
|
||||||
shift
|
shift
|
||||||
@ -531,6 +667,7 @@ mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
|||||||
render_config_json "$CONFIG_PATH"
|
render_config_json "$CONFIG_PATH"
|
||||||
render_auth_users_json "$AUTH_USERS_PATH"
|
render_auth_users_json "$AUTH_USERS_PATH"
|
||||||
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
||||||
|
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
|
||||||
|
|
||||||
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
||||||
log "building image ${IMAGE_NAME}"
|
log "building image ${IMAGE_NAME}"
|
||||||
@ -564,6 +701,7 @@ RUN_ARGS=(
|
|||||||
-e "APP_PUBLIC_PORT=8080"
|
-e "APP_PUBLIC_PORT=8080"
|
||||||
-e "APP_FRONTEND_PORT=3000"
|
-e "APP_FRONTEND_PORT=3000"
|
||||||
-e "APP_BACKEND_PORT=18080"
|
-e "APP_BACKEND_PORT=18080"
|
||||||
|
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
||||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||||
--label "beaver.instance.id=${INSTANCE_ID}"
|
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||||
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { ChatLogEvent, ChatLogSession } from '@/types';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { containedJsonTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
function eventLabel(event: ChatLogEvent): string {
|
function eventLabel(event: ChatLogEvent): string {
|
||||||
return event.event_type || event.role || 'event';
|
return event.event_type || event.role || 'event';
|
||||||
@ -175,7 +176,7 @@ export default function LogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${event.message_id ?? index}:${event.event_type}`}
|
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 flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@ -188,7 +189,7 @@ export default function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
||||||
</div>
|
</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)}
|
{body || formatPayload(event)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,7 +19,12 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} 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 { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
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') {
|
function activeTaskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||||||
if (status === 'needs_revision') return pickAppText(locale, '待修改', 'Needs revision');
|
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');
|
if (status === 'running') return pickAppText(locale, '进行中', 'Running');
|
||||||
return pickAppText(locale, '进行中', 'Active');
|
return pickAppText(locale, '进行中', 'Active');
|
||||||
}
|
}
|
||||||
@ -47,6 +52,10 @@ function loadThinkingModePreference(): boolean {
|
|||||||
return stored == null ? false : stored !== 'false';
|
return stored == null ? false : stored !== 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDocumentHidden(): boolean {
|
||||||
|
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const {
|
const {
|
||||||
@ -78,6 +87,7 @@ export default function ChatPage() {
|
|||||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||||
|
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@ -157,10 +167,11 @@ export default function ChatPage() {
|
|||||||
setSessionProcess(key, process);
|
setSessionProcess(key, process);
|
||||||
}
|
}
|
||||||
void loadActiveTask(key);
|
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
|
const nextMessages = shouldMergePending
|
||||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
? mergeServerWithPendingUsers(displayMessages, localSnapshot)
|
||||||
: detail.messages;
|
: displayMessages;
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
shouldSnapToLatestRef.current = true;
|
shouldSnapToLatestRef.current = true;
|
||||||
const last = nextMessages[nextMessages.length - 1];
|
const last = nextMessages[nextMessages.length - 1];
|
||||||
@ -217,15 +228,11 @@ export default function ChatPage() {
|
|||||||
if (data.type === 'status' && data.status === 'thinking') {
|
if (data.type === 'status' && data.status === 'thinking') {
|
||||||
setIsThinking(true);
|
setIsThinking(true);
|
||||||
} else if (data.type === 'message' && data.role === 'assistant') {
|
} 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);
|
setIsThinking(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
addMessage({
|
const rawEvidenceStatus = data.evidence_status ?? data.metadata?.evidence_status;
|
||||||
|
const evidenceStatus = rawEvidenceStatus === 'recorded' ? 'recorded' : undefined;
|
||||||
|
const assistantMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: typeof data.content === 'string' ? data.content : '',
|
content: typeof data.content === 'string' ? data.content : '',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -233,8 +240,11 @@ export default function ChatPage() {
|
|||||||
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
|
run_id: typeof data.run_id === 'string' ? data.run_id : undefined,
|
||||||
task_id: data.task_id ?? data.metadata?.task_id ?? null,
|
task_id: data.task_id ?? data.metadata?.task_id ?? null,
|
||||||
task_status: data.task_status ?? data.metadata?.task_status ?? 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 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);
|
void loadActiveTask(typeof data.session_id === 'string' ? data.session_id : useChatStore.getState().sessionId);
|
||||||
loadSessions();
|
loadSessions();
|
||||||
@ -247,14 +257,26 @@ export default function ChatPage() {
|
|||||||
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
}, [addMessage, loadActiveTask, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isThinking) {
|
const intervalMs = getSessionRefreshIntervalMs({ isLoading, isThinking, documentHidden });
|
||||||
|
if (intervalMs == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
loadSessionMessages(useChatStore.getState().sessionId);
|
const currentSessionId = useChatStore.getState().sessionId;
|
||||||
}, 1500);
|
void loadSessionMessages(currentSessionId);
|
||||||
|
void loadSessions();
|
||||||
|
}, intervalMs);
|
||||||
return () => clearInterval(timer);
|
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 scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||||
const viewport = messageViewportRef.current;
|
const viewport = messageViewportRef.current;
|
||||||
@ -359,17 +381,18 @@ export default function ChatPage() {
|
|||||||
await loadSessions();
|
await loadSessions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addMessage({
|
const assistantMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: result.response,
|
content: result.response,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
run_id: result.run_id,
|
run_id: result.run_id,
|
||||||
task_id: result.task_id,
|
task_id: result.task_id,
|
||||||
task_status: result.task_status,
|
task_status: result.task_status,
|
||||||
validation_status: result.validation_result
|
evidence_status: result.evidence_status === 'recorded' ? 'recorded' : undefined,
|
||||||
? (result.validation_result.accepted === true ? 'passed' : 'failed')
|
} as const;
|
||||||
: 'unknown',
|
if (shouldDisplayChatMessage(assistantMessage)) {
|
||||||
});
|
addMessage(assistantMessage);
|
||||||
|
}
|
||||||
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
void getSessionProcess(sessionId).then((process) => setSessionProcess(sessionId, process)).catch(() => null);
|
||||||
void loadActiveTask(sessionId);
|
void loadActiveTask(sessionId);
|
||||||
loadSessions();
|
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]);
|
}, [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);
|
updateMessageFeedback(runId, feedbackType);
|
||||||
try {
|
try {
|
||||||
await submitChatFeedback({
|
await submitChatFeedback({
|
||||||
|
|||||||
@ -73,6 +73,7 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||||
@ -1094,7 +1095,7 @@ function ReadableFact({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className="break-words text-sm leading-5">{value || '-'}</div>
|
<div className={`text-sm leading-5 ${containedLongTextClass}`}>{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1119,12 +1120,12 @@ function MetricTile({
|
|||||||
|
|
||||||
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||||
return (
|
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">
|
<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}
|
{title}
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</summary>
|
</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)}
|
{JSON.stringify(payload, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</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 {
|
function triggerReasonLabel(reason: string, t: (zh: string, en: string) => string): string {
|
||||||
const labels: Record<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;
|
return labels[reason] || reason;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,126 +3,135 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, HelpCircle, Loader2, MessageSquare, RefreshCw, ThumbsUp, Trash2, User, XCircle } from 'lucide-react';
|
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared';
|
import {
|
||||||
import { Badge } from '@/components/ui/badge';
|
TaskLiveHeader,
|
||||||
|
TaskSideRail,
|
||||||
|
TaskTimeline,
|
||||||
|
type TaskFeedbackItem,
|
||||||
|
type TaskFeedbackType,
|
||||||
|
} from '@/components/task-detail';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api';
|
||||||
import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api';
|
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||||
|
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||||
|
import type { BackendTask } from '@/types';
|
||||||
|
|
||||||
type TaskFeedbackType = 'satisfied' | 'revise' | 'abandon';
|
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||||
type TaskFeedbackItem = {
|
const TASK_RESULT_REVIEW_ID = 'task-result-review';
|
||||||
feedback_type?: unknown;
|
|
||||||
comment?: unknown;
|
|
||||||
created_at?: unknown;
|
|
||||||
run_id?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') {
|
|
||||||
if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed');
|
|
||||||
if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
|
||||||
return task.stageLabel || task.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadText(filename: string, content: string) {
|
|
||||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ taskId: string }>();
|
const params = useParams<{ taskId: string }>();
|
||||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
||||||
const sessions = useChatStore((state) => state.sessions);
|
|
||||||
const processRuns = useChatStore((state) => state.processRuns);
|
const processRuns = useChatStore((state) => state.processRuns);
|
||||||
const processEvents = useChatStore((state) => state.processEvents);
|
const processEvents = useChatStore((state) => state.processEvents);
|
||||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||||
|
const setSessionProcess = useChatStore((state) => state.setSessionProcess);
|
||||||
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
||||||
|
const wsStatus = useChatStore((state) => state.wsStatus);
|
||||||
|
|
||||||
const task = useMemo(
|
|
||||||
() => buildTaskRuntimeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
|
|
||||||
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
|
|
||||||
);
|
|
||||||
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
||||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
const [backendTaskLoading, setBackendTaskLoading] = useState(true);
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
|
||||||
const [revision, setRevision] = useState('');
|
const [revision, setRevision] = useState('');
|
||||||
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
|
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const mountedRef = React.useRef(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSelectedRunId(task?.rootRunId ?? null);
|
|
||||||
setRuntimeFeedback(null);
|
|
||||||
}, [task?.rootRunId]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (task || !taskId) {
|
|
||||||
setBackendTask(null);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
const loadBackendTask = React.useCallback(async () => {
|
||||||
|
if (!taskId) return null;
|
||||||
setBackendTaskLoading(true);
|
setBackendTaskLoading(true);
|
||||||
getBackendTask(taskId)
|
try {
|
||||||
.then((item) => {
|
const item = await getBackendTask(taskId);
|
||||||
if (!cancelled) setBackendTask(item);
|
if (!mountedRef.current) return item;
|
||||||
})
|
setBackendTask(item);
|
||||||
.catch(() => {
|
setSessionProcess(item.session_id, {
|
||||||
if (!cancelled) setBackendTask(null);
|
runs: item.process_runs ?? [],
|
||||||
})
|
events: item.process_events ?? [],
|
||||||
.finally(() => {
|
artifacts: item.process_artifacts ?? [],
|
||||||
if (!cancelled) setBackendTaskLoading(false);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return item;
|
||||||
cancelled = true;
|
} catch {
|
||||||
};
|
if (mountedRef.current) {
|
||||||
}, [task, taskId]);
|
setBackendTask(null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setBackendTaskLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setSessionProcess, taskId]);
|
||||||
|
|
||||||
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
|
React.useEffect(() => {
|
||||||
const artifacts = useMemo(
|
void loadBackendTask();
|
||||||
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
|
}, [loadBackendTask]);
|
||||||
[processArtifacts, runIds]
|
|
||||||
|
const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!shouldPollTaskDetail(backendTask)) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void loadBackendTask();
|
||||||
|
}, 4000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [backendTask, loadBackendTask]);
|
||||||
|
|
||||||
|
const taskRunIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
|
||||||
|
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
|
||||||
|
return ids;
|
||||||
|
}, [backendTask]);
|
||||||
|
|
||||||
|
const liveRuns = useMemo(
|
||||||
|
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
|
||||||
|
[processRuns, taskId, taskRunIds]
|
||||||
);
|
);
|
||||||
const eventsByRun = useMemo(() => {
|
|
||||||
const map = new Map<string, ProcessEvent[]>();
|
const liveEvents = useMemo(
|
||||||
for (const event of processEvents) {
|
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||||||
if (!runIds.has(event.run_id)) continue;
|
[processEvents, taskId, taskRunIds]
|
||||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
);
|
||||||
}
|
|
||||||
return map;
|
const liveArtifacts = useMemo(
|
||||||
}, [processEvents, runIds]);
|
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||||||
const artifactsByRun = useMemo(() => {
|
[processArtifacts, taskId, taskRunIds]
|
||||||
const map = new Map<string, ProcessArtifact[]>();
|
);
|
||||||
for (const artifact of artifacts) {
|
|
||||||
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
|
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
|
||||||
}
|
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
|
||||||
return map;
|
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
|
||||||
}, [artifacts]);
|
|
||||||
const phaseGroups = useMemo(() => {
|
const timelineCards = useMemo(
|
||||||
const groups = new Map<string, TaskRuntimeNodeView[]>();
|
() =>
|
||||||
for (const item of task?.tasks ?? []) {
|
backendTask
|
||||||
const label = item.stageLabel || taskVisibleStatus(item, locale);
|
? buildTaskTimelineCards({
|
||||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
task: backendTask,
|
||||||
}
|
processRuns: renderedRuns,
|
||||||
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
|
processEvents: renderedEvents,
|
||||||
}, [locale, task?.tasks]);
|
processArtifacts: renderedArtifacts,
|
||||||
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
|
})
|
||||||
|
: [],
|
||||||
|
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeLabel =
|
||||||
|
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||||||
|
const durationMs = backendTask ? taskDetailDurationMs(backendTask) : null;
|
||||||
|
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||||
|
|
||||||
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||||
setActionBusy(key);
|
setActionBusy(key);
|
||||||
@ -148,28 +157,16 @@ export default function TaskDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
if (backendTask) {
|
||||||
|
|
||||||
if (!task && backendTask) {
|
|
||||||
const validation = backendTask.validation_result;
|
|
||||||
const accepted = Boolean(validation?.accepted);
|
|
||||||
const validationIssues = [
|
|
||||||
...arrayOfStrings(validation?.issues),
|
|
||||||
...arrayOfStrings(validation?.missing_requirements),
|
|
||||||
];
|
|
||||||
const feedbackItems = backendTask.feedback || [];
|
const feedbackItems = backendTask.feedback || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
|
||||||
<Button asChild variant="outline" className="w-fit">
|
|
||||||
<Link href="/tasks">
|
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<div className="space-y-4">
|
||||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
<div className="flex justify-end">
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
|
||||||
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -181,113 +178,51 @@ export default function TaskDetailPage() {
|
|||||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
{actionError ? (
|
||||||
<CardContent className="p-5">
|
<Card className="border-destructive">
|
||||||
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
|
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||||
{backendTask.description ? (
|
<AlertCircle className="h-4 w-4" />
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
|
{actionError}
|
||||||
) : null}
|
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
|
|
||||||
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
|
|
||||||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TaskFeedbackPanel
|
<TaskTimeline
|
||||||
sessionId={backendTask.session_id}
|
cards={timelineCards}
|
||||||
runId={backendFeedbackRunId}
|
isLive={isTaskLive && wsStatus === 'connected'}
|
||||||
taskStatus={backendTask.status}
|
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||||
feedbackItems={feedbackItems}
|
resultAcceptance={{
|
||||||
actionBusy={actionBusy}
|
sessionId: backendTask.session_id,
|
||||||
onSubmit={(feedbackType, comment) =>
|
runId: feedbackRunId,
|
||||||
|
taskStatus: backendTask.status,
|
||||||
|
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||||
|
actionBusy,
|
||||||
|
revision,
|
||||||
|
onRevisionChange: setRevision,
|
||||||
|
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||||
|
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||||
await submitChatFeedback({
|
await submitChatFeedback({
|
||||||
sessionId: backendTask.session_id,
|
sessionId: backendTask.session_id,
|
||||||
runId: backendFeedbackRunId!,
|
runId: feedbackRunId,
|
||||||
feedbackType,
|
feedbackType,
|
||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
const refreshed = await getBackendTask(backendTask.task_id);
|
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||||
setBackendTask(refreshed);
|
setRevision('');
|
||||||
})
|
await loadBackendTask();
|
||||||
}
|
}),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BackendExecutionStages task={backendTask} />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-5">
|
|
||||||
{(backendTask.runs ?? []).length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
|
|
||||||
) : (
|
|
||||||
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '验证和反馈', 'Validation and feedback')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<div className="rounded-lg border border-border bg-muted/25 p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{validation ? (
|
|
||||||
accepted ? <CheckCircle2 className="h-5 w-5 text-[#657162]" /> : <XCircle className="h-5 w-5 text-destructive" />
|
|
||||||
) : (
|
|
||||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="font-medium">
|
|
||||||
{validation
|
|
||||||
? accepted
|
|
||||||
? pickAppText(locale, '验证通过', 'Validation passed')
|
|
||||||
: pickAppText(locale, '需要继续修改', 'Needs revision')
|
|
||||||
: pickAppText(locale, '尚未验证', 'Not validated yet')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{validation ? (
|
|
||||||
<div className="mt-2 text-muted-foreground">
|
|
||||||
{pickAppText(locale, '评分', 'Score')}: {String(validation.score ?? '-')} · {pickAppText(locale, '验证器', 'Validator')}: {String(validation.validator ?? '-')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{validationIssues.length > 0 && (
|
|
||||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-muted-foreground">
|
|
||||||
{validationIssues.map((item, index) => <li key={`${item}:${index}`}>{item}</li>)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{typeof validation?.recommended_revision_prompt === 'string' && validation.recommended_revision_prompt && (
|
|
||||||
<p className="mt-3 rounded-md bg-background p-3 text-muted-foreground">{validation.recommended_revision_prompt}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
|
||||||
<div className="font-medium">{pickAppText(locale, '用户反馈', 'User feedback')}</div>
|
</main>
|
||||||
{feedbackItems.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground">{pickAppText(locale, '还没有用户反馈。', 'No user feedback yet.')}</p>
|
|
||||||
) : (
|
|
||||||
feedbackItems.map((item, index) => (
|
|
||||||
<div key={index} className="rounded-md border border-border p-3">
|
|
||||||
<div className="font-medium">{humanFeedback(String(item.feedback_type || ''), locale)}</div>
|
|
||||||
{item.comment ? <p className="mt-1 text-muted-foreground">{String(item.comment)}</p> : null}
|
|
||||||
{item.created_at ? <p className="mt-1 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(item.created_at), locale)}</p> : null}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||||
<Button asChild variant="outline" className="w-fit">
|
<Button asChild variant="outline" className="w-fit">
|
||||||
@ -298,11 +233,14 @@ export default function TaskDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Card className="border-dashed">
|
<Card className="border-dashed">
|
||||||
<CardContent className="py-16 text-center">
|
<CardContent className="py-16 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{backendTaskLoading ? <Loader2 className="mb-4 h-5 w-5 animate-spin text-muted-foreground" /> : null}
|
||||||
|
</div>
|
||||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{backendTaskLoading
|
{backendTaskLoading
|
||||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||||
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
|
: pickAppText(locale, '后端任务库里没有这个任务。', 'The backend task store does not contain this task.')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -310,527 +248,6 @@ export default function TaskDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressValue = progressPercent(task.progress.value, task.progress.max);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
|
|
||||||
<TaskRuntimeStatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
|
|
||||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
|
|
||||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)}</span>
|
|
||||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
|
|
||||||
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
|
|
||||||
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
|
|
||||||
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
|
|
||||||
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.stats.alertCount)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{task.progress.label}</span>
|
|
||||||
<span className="font-medium">{progressValue}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
|
||||||
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{actionError && (
|
|
||||||
<Card className="border-destructive">
|
|
||||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{actionError}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{phaseGroups.map((phase, index) => (
|
|
||||||
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
|
|
||||||
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
|
||||||
<div className="font-medium">{phase.label}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
|
|
||||||
</div>
|
|
||||||
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{phaseGroups.map((phase) => (
|
|
||||||
<Card key={phase.label}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{phase.label}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
|
||||||
{phase.nodes.map((node) => (
|
|
||||||
<button
|
|
||||||
key={node.runId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedRunId(node.runId)}
|
|
||||||
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">{node.title}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={node.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
{node.summary || taskVisibleStatus(node, locale)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
|
|
||||||
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{selectedNode ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{selectedNode.title}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={selectedNode.status} />
|
|
||||||
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
|
|
||||||
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
|
|
||||||
<div className="font-medium">{event.kind}</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">{event.text || formatTaskRuntimeTime(event.created_at, locale)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">-</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<TaskFeedbackPanel
|
|
||||||
sessionId={task.sessionId || 'web:default'}
|
|
||||||
runId={task.rootRunId}
|
|
||||||
taskStatus={task.status}
|
|
||||||
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
revision={revision}
|
|
||||||
onRevisionChange={setRevision}
|
|
||||||
onSubmit={(feedbackType, comment) =>
|
|
||||||
runAction(`runtime-feedback-${feedbackType}`, async () => {
|
|
||||||
updateMessageFeedback(task.rootRunId, feedbackType);
|
|
||||||
await submitChatFeedback({
|
|
||||||
sessionId: task.sessionId || 'web:default',
|
|
||||||
runId: task.rootRunId,
|
|
||||||
feedbackType,
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
setRuntimeFeedback({
|
|
||||||
feedback_type: feedbackType,
|
|
||||||
comment: comment || '',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
run_id: task.rootRunId,
|
|
||||||
});
|
|
||||||
setRevision('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={artifacts.length === 0}
|
|
||||||
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '全部下载', 'Download all')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{artifacts.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
|
||||||
) : (
|
|
||||||
artifacts.map((artifact) => (
|
|
||||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="truncate">{artifact.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
|
||||||
</div>
|
|
||||||
{artifact.url || artifact.file_id ? (
|
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Download className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{pickAppText(locale, '下载', 'Download')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{pickAppText(locale, '下载', 'Download')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackendExecutionStages({ task }: { task: BackendTask }) {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
const runs = task.process_runs ?? [];
|
|
||||||
const events = task.process_events ?? [];
|
|
||||||
const eventsByRun = React.useMemo(() => {
|
|
||||||
const map = new Map<string, ProcessEvent[]>();
|
|
||||||
for (const event of events) {
|
|
||||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{runs.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
|
||||||
) : (
|
|
||||||
runs.map((run) => (
|
|
||||||
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
const metadata = run.metadata ?? {};
|
|
||||||
const details = [
|
|
||||||
metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null,
|
|
||||||
metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null,
|
|
||||||
metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null,
|
|
||||||
metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null,
|
|
||||||
metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border bg-background p-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">{run.title || run.actor_name}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{run.actor_name}
|
|
||||||
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={run.status} />
|
|
||||||
</div>
|
|
||||||
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
|
||||||
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
|
||||||
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
|
||||||
{events.length > 0 ? (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{events.map((event) => (
|
|
||||||
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="font-medium">{event.actor_name}</span>
|
|
||||||
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TaskFeedbackPanel({
|
|
||||||
sessionId,
|
|
||||||
runId,
|
|
||||||
taskStatus,
|
|
||||||
feedbackItems,
|
|
||||||
actionBusy,
|
|
||||||
revision,
|
|
||||||
onRevisionChange,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
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 { locale } = useAppI18n();
|
|
||||||
const [localComment, setLocalComment] = React.useState('');
|
|
||||||
const comment = revision ?? localComment;
|
|
||||||
const setComment = onRevisionChange ?? setLocalComment;
|
|
||||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
|
||||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
|
||||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy;
|
|
||||||
|
|
||||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
|
||||||
if (!runId || !canSubmit) return;
|
|
||||||
void onSubmit(feedbackType, nextComment);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '任务反馈', 'Task feedback')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent 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, '已提交反馈', 'Feedback submitted')}: {humanFeedback(String(recordedFeedback.feedback_type || ''), locale)}
|
|
||||||
</div>
|
|
||||||
{recordedFeedback.comment ? (
|
|
||||||
<p className="mt-2 text-muted-foreground">{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 feedback.')}
|
|
||||||
</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 feedback yet.')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
<FeedbackButton
|
|
||||||
type="satisfied"
|
|
||||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '满意', 'Satisfied')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
onClick={() => submit('satisfied', comment.trim() || undefined)}
|
|
||||||
/>
|
|
||||||
<FeedbackButton
|
|
||||||
type="revise"
|
|
||||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit || !comment.trim()}
|
|
||||||
onClick={() => submit('revise', comment.trim())}
|
|
||||||
/>
|
|
||||||
<FeedbackButton
|
|
||||||
type="abandon"
|
|
||||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
onClick={() => submit('abandon', comment.trim() || undefined)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
value={comment}
|
|
||||||
onChange={(event) => setComment(event.target.value)}
|
|
||||||
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
|
||||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;满意或放弃可选填说明。', 'Describe requested changes; notes are optional for satisfied or abandon.')}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{pickAppText(locale, '反馈将记录到当前任务运行:', 'Feedback 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>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackButton({
|
|
||||||
type,
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
actionBusy,
|
|
||||||
disabled,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
type: TaskFeedbackType;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
actionBusy: string | null;
|
|
||||||
disabled: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const isBusy = 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border bg-background p-4">
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{run.started_at ? formatTaskRuntimeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
|
|
||||||
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
|
|
||||||
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{run.messages.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{run.messages.map((message, messageIndex) => {
|
|
||||||
const isAssistant = message.role === 'assistant';
|
|
||||||
const isTool = message.role === 'tool';
|
|
||||||
const Icon = isAssistant ? Bot : isTool ? FileText : User;
|
|
||||||
return (
|
|
||||||
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
|
|
||||||
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
|
|
||||||
{message.created_at ? <span>{formatTaskRuntimeTime(message.created_at, locale)}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
|
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
const map: Record<string, [string, string]> = {
|
|
||||||
open: ['已创建', 'Open'],
|
|
||||||
running: ['执行中', 'Running'],
|
|
||||||
validating: ['验证中', 'Validating'],
|
|
||||||
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
|
|
||||||
needs_revision: ['需要修改', 'Needs revision'],
|
|
||||||
closed: ['已完成', 'Closed'],
|
|
||||||
abandoned: ['已放弃', 'Abandoned'],
|
|
||||||
};
|
|
||||||
const item = map[status];
|
|
||||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
if (type === 'satisfied') return pickAppText(locale, '满意', 'Satisfied');
|
|
||||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
|
||||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
|
||||||
return type || pickAppText(locale, '反馈', 'Feedback');
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
|
|
||||||
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
|
|
||||||
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFeedbackRunId(task: BackendTask): string | null {
|
function pickFeedbackRunId(task: BackendTask): string | null {
|
||||||
const runIds = task.run_ids.filter(Boolean);
|
const runIds = task.run_ids.filter(Boolean);
|
||||||
if (runIds.length > 0) return runIds[runIds.length - 1];
|
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||||||
@ -838,17 +255,3 @@ function pickFeedbackRunId(task: BackendTask): string | null {
|
|||||||
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
|
||||||
if (!runId) return null;
|
|
||||||
const ordered = [...items].reverse();
|
|
||||||
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
|
||||||
return [...items].reverse()[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayOfStrings(value: unknown): string[] {
|
|
||||||
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -142,7 +142,7 @@ function OrdinaryTasks() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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)}
|
{taskStatusLabel(task.status, locale)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -185,8 +185,7 @@ function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
|||||||
const labels: Record<string, [string, string]> = {
|
const labels: Record<string, [string, string]> = {
|
||||||
open: ['已创建', 'Open'],
|
open: ['已创建', 'Open'],
|
||||||
running: ['执行中', 'Running'],
|
running: ['执行中', 'Running'],
|
||||||
validating: ['验证中', 'Validating'],
|
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
||||||
awaiting_feedback: ['等待反馈', 'Awaiting feedback'],
|
|
||||||
needs_revision: ['需要修改', 'Needs revision'],
|
needs_revision: ['需要修改', 'Needs revision'],
|
||||||
closed: ['已完成', 'Closed'],
|
closed: ['已完成', 'Closed'],
|
||||||
abandoned: ['已放弃', 'Abandoned'],
|
abandoned: ['已放弃', 'Abandoned'],
|
||||||
|
|||||||
@ -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 */
|
/* Override Tailwind Typography table defaults for markdown rendering */
|
||||||
.prose table {
|
.prose table {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export function ChatWorkbench({
|
|||||||
processArtifacts: ProcessArtifact[];
|
processArtifacts: ProcessArtifact[];
|
||||||
selectedRunId: string | null;
|
selectedRunId: string | null;
|
||||||
onSelectRun: (runId: string) => void;
|
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;
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
export function MarkdownContent({ content }: { content: string }) {
|
export function MarkdownContent({ content }: { content: string }) {
|
||||||
return (
|
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
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, Thumbs
|
|||||||
|
|
||||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
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 { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||||
|
|
||||||
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
function AuthImage({ src, alt, className }: { src: string; alt: string; className?: string }) {
|
||||||
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
const [blobUrl, setBlobUrl] = React.useState<string | null>(null);
|
||||||
@ -49,19 +50,14 @@ function MessageBubble({
|
|||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
showTaskCard: boolean;
|
showTaskCard: boolean;
|
||||||
canSendFeedback: 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;
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const textContent = typeof message.content === 'string' ? message.content : String(message.content || '');
|
const textContent = normalizedMessageText(message.content);
|
||||||
const [feedbackMode, setFeedbackMode] = React.useState<'satisfied' | null>(null);
|
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
|
||||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
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 (
|
return (
|
||||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||||
@ -71,7 +67,7 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`max-w-[88%] px-4 py-3 ${
|
className={`min-w-0 max-w-[88%] px-4 py-3 ${
|
||||||
isUser
|
isUser
|
||||||
? 'rounded-[28px] bg-primary text-primary-foreground'
|
? 'rounded-[28px] bg-primary text-primary-foreground'
|
||||||
: 'rounded-none bg-transparent text-[#1D1715]'
|
: 'rounded-none bg-transparent text-[#1D1715]'
|
||||||
@ -97,14 +93,14 @@ function MessageBubble({
|
|||||||
key={att.file_id}
|
key={att.file_id}
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
download={att.name}
|
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
|
isUser
|
||||||
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
? 'bg-primary-foreground/10 hover:bg-primary-foreground/20'
|
||||||
: 'bg-muted hover:bg-muted/80'
|
: 'bg-muted hover:bg-muted/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Paperclip className="w-3.5 h-3.5 flex-shrink-0" />
|
<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 && (
|
{att.size && (
|
||||||
<span className="text-xs opacity-70 flex-shrink-0">
|
<span className="text-xs opacity-70 flex-shrink-0">
|
||||||
{att.size > 1024 * 1024
|
{att.size > 1024 * 1024
|
||||||
@ -119,7 +115,7 @@ function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
<p className="text-sm whitespace-pre-wrap">{textContent}</p>
|
<p className={`text-sm ${containedPreservedLongTextClass}`}>{textContent}</p>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownContent content={textContent} />
|
<MarkdownContent content={textContent} />
|
||||||
)}
|
)}
|
||||||
@ -142,22 +138,14 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{!isUser && (canSendFeedback || message.feedback_state) && message.run_id && (
|
||||||
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
<div className="mt-3 space-y-2 border-t border-border/70 pt-3">
|
||||||
{message.feedback_state ? (
|
{message.feedback_state ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
<span>
|
<span>
|
||||||
{message.feedback_state === 'satisfied'
|
{message.feedback_state === 'accept' || message.feedback_state === 'satisfied'
|
||||||
? pickAppText(locale, '已标记满意', 'Marked satisfied')
|
? pickAppText(locale, '已接受', 'Accepted')
|
||||||
: message.feedback_state === 'revise'
|
: message.feedback_state === 'revise'
|
||||||
? pickAppText(locale, '已请求修改', 'Revision requested')
|
? pickAppText(locale, '已请求修改', 'Revision requested')
|
||||||
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
: pickAppText(locale, '已放弃任务', 'Task abandoned')}
|
||||||
@ -168,11 +156,11 @@ function MessageBubble({
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<ThumbsUp className="h-3.5 w-3.5" />
|
||||||
{pickAppText(locale, '满意', 'Satisfied')}
|
{pickAppText(locale, '接受', 'Accept')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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 && (
|
{message.feedback_error && (
|
||||||
<span className="text-xs text-destructive">{message.feedback_error}</span>
|
<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 {
|
function parseTimelineTime(value?: string | null): number | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const parsed = new Date(value).getTime();
|
const parsed = new Date(value).getTime();
|
||||||
@ -342,12 +334,12 @@ export function MessageList({
|
|||||||
processArtifacts: ProcessArtifact[];
|
processArtifacts: ProcessArtifact[];
|
||||||
selectedRunId: string | null;
|
selectedRunId: string | null;
|
||||||
onSelectRun: (runId: string) => void;
|
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;
|
onRequestRevision: (runId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const visibleMessages = React.useMemo(
|
const visibleMessages = React.useMemo(
|
||||||
() => messages.filter((message) => !shouldHideSystemAgentMessage(message)),
|
() => messages.filter((message) => !shouldHideMessage(message)),
|
||||||
[messages]
|
[messages]
|
||||||
);
|
);
|
||||||
const teamGroups = React.useMemo(
|
const teamGroups = React.useMemo(
|
||||||
@ -385,14 +377,21 @@ export function MessageList({
|
|||||||
() => getTaskCardMessageIndexes(visibleMessages),
|
() => getTaskCardMessageIndexes(visibleMessages),
|
||||||
[visibleMessages]
|
[visibleMessages]
|
||||||
);
|
);
|
||||||
const latestFeedbackRunId = [...visibleMessages]
|
const latestFeedbackMessageIndex = (() => {
|
||||||
.reverse()
|
for (let index = visibleMessages.length - 1; index >= 0; index -= 1) {
|
||||||
.find((message) =>
|
const message = visibleMessages[index];
|
||||||
|
if (
|
||||||
message.role === 'assistant'
|
message.role === 'assistant'
|
||||||
&& message.run_id
|
&& message.run_id
|
||||||
&& message.task_id
|
&& message.task_id
|
||||||
&& message.task_status === 'awaiting_feedback'
|
&& message.task_status === 'awaiting_acceptance'
|
||||||
)?.run_id;
|
&& hasRenderableMessageContent(message)
|
||||||
|
) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||||
@ -411,7 +410,7 @@ export function MessageList({
|
|||||||
key={item.key}
|
key={item.key}
|
||||||
message={item.message}
|
message={item.message}
|
||||||
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
|
||||||
canSendFeedback={Boolean(latestFeedbackRunId && item.message.run_id === latestFeedbackRunId)}
|
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
|
||||||
onFeedback={onFeedback}
|
onFeedback={onFeedback}
|
||||||
onRequestRevision={onRequestRevision}
|
onRequestRevision={onRequestRevision}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal file
102
app-instance/frontend/components/task-detail/TaskLiveHeader.tsx
Normal 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
Reference in New Issue
Block a user