Backend Runtime
Backend Runtime:服务端会话与运行边界
s02 通过工具定义、注册表和统一分发,让 Agent Loop 能够使用多个工具。 但能够调用工具,不等于已经具备可供多个客户端接入的后端运行时。
如果浏览器每次请求都提交完整消息历史,FastAPI 只是一次无状态的函数调用: 服务端无法判断历史是否被删改,两个并发请求也可能从同一个旧历史开始执行。 后续加入权限审批时,后端还需要暂停一次尚未完成的执行,并在用户决定后继续, 仅靠“请求携带消息列表”无法稳定表达这些状态。
s02.5 是本地工程化中间章,它保留 s02 的工具注册与分发,新增唯一机制:
后端持有 Conversation,并用 Run 表达一次用户输入触发的 Agent 执行。
FastAPI 是 Agent 的正式服务入口,Web 只是当前接入器。浏览器只保存
conversation_id,模型消息、工具调用结果和执行顺序都由后端维护。
flowchart LR
accTitle: Backend Runtime 的职责边界
accDescr: Web 或其他接入器只提交 Conversation 标识和本次输入,FastAPI 将请求交给 ConversationService,服务加载历史后调用 AgentRunner,AgentRunner 通过模型和工具运行时完成执行,成功后由服务保存新快照。
web["Web 接入器"]
other["其他接入器"]
api["FastAPI"]
service["ConversationService"]
store[("ConversationStore")]
runner["AgentRunner"]
model["AsyncOpenAI"]
runtime["ToolRuntime"]
web -->|"conversation_id + input"| api
other -->|"conversation_id + input"| api
api --> service
service -->|"读取 / 保存"| store
service --> runner
runner --> model
runner --> runtime
runtime --> runner
runner --> service
service --> api
class api,service,store added
class runner,runtime attention
Conversation、Run 和认证 Session
| 概念 | 表达的内容 | 生命周期 |
|---|---|---|
| Conversation | 一段对话的模型历史和公开 Turn | 跨越多次用户输入 |
| Run | 一次输入触发的完整 Agent Loop 执行 | 从接收输入到完成或暂停 |
| 认证 Session | 用户身份和登录状态 | 由认证系统决定 |
Conversation 解决“下一轮模型调用需要哪些历史”,Run 解决“这一次执行进行到哪里”。 认证 Session 则回答“调用者是谁”。把它们都叫作 session,会让消息状态、执行状态 和身份状态相互耦合。
本章只实现 Conversation 和完成态 Run,不实现用户认证。客户端拿到
conversation_id 不代表它已经获得了安全授权;正式系统仍需在 API 边界验证
调用者是否有权访问该 Conversation。
项目目录
s02-5-backend-runtime/ ├── s02-5.md ├── README.md ├── main.py ├── config.py ├── bootstrap.py ├── workspace.py ├── agent/ │ ├── contract.py │ ├── loop.py │ └── service.py ├── api/ │ ├── app.py │ └── schemas.py ├── conversations/ │ ├── contract.py │ └── memory.py ├── tooling/ ├── tools/ ├── web/ │ └── agent-chat.html └── tests/
各层职责:
| 模块 | 职责 |
|---|---|
api/ | 外部请求和响应 |
agent/service.py | Run 读取、执行并提交 Conversation |
agent/loop.py | 模型和工具循环 |
conversations/ | Conversation 表示和保存 |
tooling/、tools/ | 工具注册、分发和执行 |
web/ | 浏览器接入器调用后端 |
依赖从协议边界指向应用服务,再指向 Agent 与存储契约。 Agent Loop 不导入 FastAPI,也不判断客户端类型;Store 不依赖模型或工具。 因此更换 Web、CLI、数据库或模型客户端时,不需要把全部职责重新组合一次。
项目代码
MODEL_API_KEY=
BASE_URL=
MODEL=
HOST=127.0.0.1
PORT=8100
.env
__pycache__/
*.pyc
.mypy_cache/
.ruff_cache/
"""Agent 模型循环与 Conversation 应用服务。"""
"""Agent 执行契约:隔离会话编排与具体模型循环。"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from openai.types.chat import ChatCompletionMessageParam
@dataclass(frozen=True)
class AgentResult:
answer: str
messages: tuple[ChatCompletionMessageParam, ...]
class AgentRunner(Protocol):
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
"""基于历史完成一次运行,返回最终回答和完整的新历史。"""
...
"""OpenAI-compatible 工具调用循环。"""
from __future__ import annotations
from collections.abc import Sequence
from copy import deepcopy
from typing import cast
from openai import AsyncOpenAI, omit
from openai.types.chat import (
ChatCompletionAssistantMessageParam,
ChatCompletionMessageParam,
)
from tooling.runtime import ToolRuntime
from .contract import AgentResult
class ToolCallingAgent:
"""持有模型依赖;每次运行只修改本次创建的消息副本。"""
def __init__(
self,
runtime: ToolRuntime,
*,
client: AsyncOpenAI,
model: str,
system_prompt: str,
) -> None:
self._runtime = runtime
self._client = client
self._model = model
self._system_prompt = system_prompt
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
messages: list[ChatCompletionMessageParam] = [
{"role": "system", "content": self._system_prompt},
*deepcopy(list(history)),
]
schemas = self._runtime.schemas()
while True:
response = await self._client.chat.completions.create(
model=self._model,
messages=messages,
tools=schemas or omit,
tool_choice="auto" if schemas else omit,
)
message = response.choices[0].message
messages.append(
cast(
ChatCompletionAssistantMessageParam,
message.model_dump(exclude_none=True),
)
)
tool_calls = message.tool_calls or []
if not tool_calls:
return AgentResult(
answer=message.content or "",
messages=tuple(messages[1:]),
)
for call in tool_calls:
if call.type != "function":
raise ValueError(f"Unsupported tool call type: {call.type}")
output = await self._runtime.execute(
call.function.name,
call.function.arguments,
)
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": output,
}
)
"""Conversation 应用服务:串行运行并原子提交消息历史。"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from uuid import UUID, uuid4
from conversations.contract import (
Conversation,
ConversationNotFoundError,
ConversationStore,
ConversationTurn,
)
from openai.types.chat import ChatCompletionMessageParam
from .contract import AgentRunner
@dataclass(frozen=True)
class CompletedRun:
conversation_id: UUID
run_id: UUID
message: str
class ConversationService:
"""维护 Conversation 生命周期;同一会话同一时刻只执行一个 Run。"""
def __init__(
self,
store: ConversationStore,
runner: AgentRunner,
*,
run_id_factory: Callable[[], UUID] = uuid4,
) -> None:
self._store = store
self._runner = runner
self._run_id_factory = run_id_factory
self._locks: dict[UUID, asyncio.Lock] = {}
async def create(self) -> Conversation:
return await self._store.create()
async def get(self, conversation_id: UUID) -> Conversation:
conversation = await self._store.get(conversation_id)
if conversation is None:
raise ConversationNotFoundError(conversation_id)
return conversation
async def run(self, conversation_id: UUID, user_input: str) -> CompletedRun:
if not user_input.strip():
raise ValueError("input must not be blank")
# 当前章节没有删除会话;先查存在性可避免为任意无效 ID 留下锁。
await self.get(conversation_id)
lock = self._locks.setdefault(conversation_id, asyncio.Lock())
async with lock:
conversation = await self.get(conversation_id)
run_id = self._run_id_factory()
user_message: ChatCompletionMessageParam = {
"role": "user",
"content": user_input,
}
history = (*conversation.messages, user_message)
# runner 失败时不会执行 save,原 Conversation 快照保持不变。
result = await self._runner.run(history)
turn = ConversationTurn(
run_id=run_id,
user_input=user_input,
assistant_output=result.answer,
)
updated = Conversation(
conversation_id=conversation_id,
messages=result.messages,
turns=(*conversation.turns, turn),
)
await self._store.save(updated)
return CompletedRun(
conversation_id=conversation_id,
run_id=run_id,
message=result.answer,
)
"""FastAPI 协议适配层。"""
"""FastAPI 入口:Conversation 与 Run 是正式后端协议。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import cast
from uuid import UUID
from agent.loop import ToolCallingAgent
from agent.service import ConversationService
from bootstrap import build_runtime
from config import create_client, load_settings
from conversations.contract import ConversationNotFoundError
from conversations.memory import InMemoryConversationStore
from fastapi import FastAPI, Request, status
from fastapi.responses import FileResponse, JSONResponse
from workspace import WORKDIR
from .schemas import (
CompletedRunResponse,
ConversationResponse,
CreateConversationResponse,
CreateRunRequest,
ErrorDetail,
ErrorResponse,
)
HTML_FILE = Path(__file__).resolve().parents[1] / "web" / "agent-chat.html"
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncGenerator[None]:
"""进程启动时组装后端依赖,关闭时释放模型 HTTP 连接。"""
settings = load_settings()
runtime = build_runtime()
store = InMemoryConversationStore()
system_prompt = (
f"You are a coding agent at {WORKDIR}. "
"Use tools to solve tasks. Act, don't explain."
)
async with create_client(settings) as client:
runner = ToolCallingAgent(
runtime,
client=client,
model=settings.model,
system_prompt=system_prompt,
)
application.state.conversation_service = ConversationService(store, runner)
yield
app = FastAPI(
title="s02.5 Backend Runtime",
version="0.1.0",
lifespan=lifespan,
)
def get_service(request: Request) -> ConversationService:
return cast(ConversationService, request.app.state.conversation_service)
@app.exception_handler(ConversationNotFoundError)
async def conversation_not_found(
_request: Request,
exc: ConversationNotFoundError,
) -> JSONResponse:
payload = ErrorResponse(
error=ErrorDetail(
code="conversation_not_found",
message=str(exc),
)
)
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND, content=payload.model_dump(mode="json")
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post(
"/api/conversations",
response_model=CreateConversationResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_conversation(request: Request) -> CreateConversationResponse:
conversation = await get_service(request).create()
return CreateConversationResponse(conversation_id=conversation.conversation_id)
@app.get(
"/api/conversations/{conversation_id}",
response_model=ConversationResponse,
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}},
)
async def get_conversation(
conversation_id: UUID,
request: Request,
) -> ConversationResponse:
conversation = await get_service(request).get(conversation_id)
return ConversationResponse.from_domain(conversation)
@app.post(
"/api/conversations/{conversation_id}/runs",
response_model=CompletedRunResponse,
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}},
)
async def create_run(
conversation_id: UUID,
payload: CreateRunRequest,
request: Request,
) -> CompletedRunResponse:
run = await get_service(request).run(conversation_id, payload.input)
return CompletedRunResponse.from_domain(run)
@app.get("/", response_class=FileResponse)
async def index() -> FileResponse:
return FileResponse(HTML_FILE, media_type="text/html")
"""HTTP 请求与响应模型。"""
from __future__ import annotations
from typing import Literal, Self
from uuid import UUID
from agent.service import CompletedRun
from conversations.contract import Conversation
from pydantic import BaseModel, ConfigDict, field_validator
class ApiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class MessageResponse(ApiModel):
role: Literal["assistant"] = "assistant"
content: str
class CreateConversationResponse(ApiModel):
conversation_id: UUID
class ConversationTurnResponse(ApiModel):
run_id: UUID
input: str
message: MessageResponse
class ConversationResponse(ApiModel):
conversation_id: UUID
turns: list[ConversationTurnResponse]
@classmethod
def from_domain(cls, conversation: Conversation) -> Self:
return cls(
conversation_id=conversation.conversation_id,
turns=[
ConversationTurnResponse(
run_id=turn.run_id,
input=turn.user_input,
message=MessageResponse(content=turn.assistant_output),
)
for turn in conversation.turns
],
)
class CreateRunRequest(ApiModel):
input: str
@field_validator("input")
@classmethod
def reject_blank_input(cls, value: str) -> str:
if not value.strip():
raise ValueError("input must not be blank")
return value
class CompletedRunResponse(ApiModel):
"""`status` 是后续增加其他 Run 状态时的判别字段。"""
status: Literal["completed"] = "completed"
conversation_id: UUID
run_id: UUID
message: MessageResponse
@classmethod
def from_domain(cls, run: CompletedRun) -> Self:
return cls(
conversation_id=run.conversation_id,
run_id=run.run_id,
message=MessageResponse(content=run.message),
)
class ErrorDetail(ApiModel):
code: str
message: str
class ErrorResponse(ApiModel):
error: ErrorDetail
"""工具组装:显式选择后端进程启用的能力。"""
from tooling.registry import ToolRegistry
from tooling.runtime import ToolRuntime
from tools.filesystem import EDIT_FILE, GLOB, READ_FILE, WRITE_FILE
from tools.shell import BASH
def build_runtime() -> ToolRuntime:
"""启用 s02 已学到的五个工具。"""
registry = ToolRegistry([BASH, READ_FILE, WRITE_FILE, EDIT_FILE, GLOB])
return ToolRuntime(registry)
"""后端进程配置与模型客户端工厂。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from openai import AsyncOpenAI
CHAPTER_ROOT = Path(__file__).resolve().parent
load_dotenv(CHAPTER_ROOT / ".env")
@dataclass(frozen=True)
class Settings:
model: str
api_key: str | None
base_url: str | None
host: str
port: int
def load_settings() -> Settings:
"""从进程环境读取配置;同目录 .env 只提供未设置项。"""
return Settings(
model=os.getenv("MODEL") or "doubao-seed-evolving",
api_key=os.getenv("MODEL_API_KEY"),
base_url=os.getenv("BASE_URL") or None,
host=os.getenv("HOST") or "127.0.0.1",
port=int(os.getenv("PORT") or "8100"),
)
def create_client(settings: Settings) -> AsyncOpenAI:
"""客户端由 FastAPI lifespan 创建和关闭。"""
return AsyncOpenAI(
api_key=settings.api_key,
base_url=settings.base_url,
)
"""Conversation 领域模型与存储实现。"""
"""Conversation 领域模型与存储契约。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from uuid import UUID
from openai.types.chat import ChatCompletionMessageParam
@dataclass(frozen=True)
class ConversationTurn:
run_id: UUID
user_input: str
assistant_output: str
@dataclass(frozen=True)
class Conversation:
conversation_id: UUID
messages: tuple[ChatCompletionMessageParam, ...] = ()
turns: tuple[ConversationTurn, ...] = ()
class ConversationStore(Protocol):
async def create(self) -> Conversation:
"""创建并持久化空 Conversation。"""
...
async def get(self, conversation_id: UUID) -> Conversation | None:
"""返回独立快照;不存在时返回 None。"""
...
async def save(self, conversation: Conversation) -> None:
"""替换已存在 Conversation 的完整快照。"""
...
class ConversationNotFoundError(LookupError):
def __init__(self, conversation_id: UUID) -> None:
self.conversation_id = conversation_id
super().__init__(f"Conversation not found: {conversation_id}")
"""单进程内存 Conversation 存储。"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from copy import deepcopy
from uuid import UUID, uuid4
from .contract import Conversation, ConversationNotFoundError
class InMemoryConversationStore:
"""保存独立快照;进程重启或多 worker 间不会共享数据。"""
def __init__(self, id_factory: Callable[[], UUID] = uuid4) -> None:
self._id_factory = id_factory
self._conversations: dict[UUID, Conversation] = {}
self._lock = asyncio.Lock()
async def create(self) -> Conversation:
conversation = Conversation(conversation_id=self._id_factory())
async with self._lock:
if conversation.conversation_id in self._conversations:
raise ValueError(
f"Duplicate conversation id: {conversation.conversation_id}"
)
self._conversations[conversation.conversation_id] = conversation
return deepcopy(conversation)
async def get(self, conversation_id: UUID) -> Conversation | None:
async with self._lock:
conversation = self._conversations.get(conversation_id)
return deepcopy(conversation)
async def save(self, conversation: Conversation) -> None:
async with self._lock:
if conversation.conversation_id not in self._conversations:
raise ConversationNotFoundError(conversation.conversation_id)
self._conversations[conversation.conversation_id] = deepcopy(conversation)
"""s02.5 后端服务入口。
运行:
uv run s02-5-backend-runtime/main.py
"""
from __future__ import annotations
import uvicorn
from api.app import app
from config import load_settings
def main() -> None:
settings = load_settings()
uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == "__main__":
main()
# s02.5: Backend Runtime
本地工程化中间章,把 s02 的工具型 Agent 放入正式 FastAPI 后端。
上游 s02 的唯一机制仍是:
> 用工具定义、注册表与统一分发扩展模型可调用的能力。
s02.5 不对应上游新章节,也不改变这项机制。它解决的是宿主边界:由后端进程
管理模型客户端、工具运行时和对话历史,通过稳定 HTTP 协议向 Web 或其他接入器
提供能力。
## 核心模型
- **Conversation**:服务端保存的一段对话及 OpenAI 消息历史。
- **Run**:一次用户输入触发的完整 Agent Loop 执行。
- **认证 Session**:用户身份或登录状态,本章不实现。
浏览器只保存 `conversation_id`,不会提交完整历史。后续接入 CLI、IM 或其他
客户端时,可以复用同一 Conversation/Run API。
## 目录结构
```text
s02-5-backend-runtime/
├── s02-5.md # 本地中间章学习笔记
├── agent/
│ ├── contract.py # AgentRunner 与 AgentResult
│ ├── loop.py # OpenAI 工具调用循环
│ └── service.py # Conversation/Run 编排与并发控制
├── api/
│ ├── app.py # FastAPI、lifespan 与路由
│ └── schemas.py # Pydantic HTTP 契约
├── conversations/
│ ├── contract.py # 领域模型与 Store 协议
│ └── memory.py # 单进程内存实现
├── tooling/ # s02 工具注册与分发机制
├── tools/ # bash 与文件工具
├── web/agent-chat.html # 当前 Web 接入器
├── tests/
├── bootstrap.py
├── config.py
├── main.py
└── workspace.py
```
依赖方向为:
```text
HTTP/Web -> ConversationService -> AgentRunner -> ToolRuntime
|
+-------------> ConversationStore
```
FastAPI lifespan 在进程启动时创建一个 `AsyncOpenAI`、工具运行时、
`InMemoryConversationStore` 和 `ConversationService`,关闭时释放模型客户端。
模块导入不会提前创建网络客户端。
## HTTP API
创建 Conversation:
```http
POST /api/conversations
```
```json
{
"conversation_id": "6dd71267-1c81-4d31-a16b-cfdbcbd86d1b"
}
```
执行 Run:
```http
POST /api/conversations/6dd71267-1c81-4d31-a16b-cfdbcbd86d1b/runs
Content-Type: application/json
{"input": "读取 README.md 并总结"}
```
```json
{
"status": "completed",
"conversation_id": "6dd71267-1c81-4d31-a16b-cfdbcbd86d1b",
"run_id": "02bf49da-f12a-402f-aee2-0aa75f47d9bf",
"message": {
"role": "assistant",
"content": "..."
}
}
```
读取历史:
```http
GET /api/conversations/6dd71267-1c81-4d31-a16b-cfdbcbd86d1b
```
不存在的 Conversation 使用结构化错误:
```json
{
"error": {
"code": "conversation_not_found",
"message": "Conversation not found: ..."
}
}
```
`status: "completed"` 是 Run 响应的判别字段。s03 可在不改变已完成响应的前提下,
增加 `approval_required` 等状态;本章不实现权限或暂停恢复。
## 状态与并发语义
`ConversationService` 在本地副本上追加用户消息并运行 Agent。只有 Agent 正常返回后,
完整消息历史和公开 Turn 才会一起写回 Store。模型异常或 handler 编程错误不会提交
半成品历史。
同一 Conversation 的 Run 通过独立 `asyncio.Lock` 串行执行,后一个 Run 会看到
前一个成功 Run 的最新历史;不同 Conversation 可以并行。锁位于应用服务而不是
Agent Loop,模型协议和并发策略不会混在一起。
原子提交只覆盖 Conversation 状态。已经执行的 Bash、写文件或外部调用具有真实
副作用,后续模型失败不会自动回滚这些操作。
## 存储边界
`InMemoryConversationStore` 用于明确服务端持有状态的机制,但有严格限制:
- 进程重启后数据丢失。
- 多 worker 之间不共享数据。
- 不支持跨进程并发控制。
- 不包含用户身份、访问控制、过期回收和容量治理。
因此当前只能以单 worker 运行。替换为数据库时,应在 Store 实现中加入版本检查或
事务,并把会话级互斥升级为跨进程协调;不能直接把当前内存锁当成生产并发保证。
## 运行
复制 `.env.example` 为 `.env`,填写 OpenAI-compatible 模型配置:
```text
MODEL_API_KEY=
BASE_URL=
MODEL=
HOST=127.0.0.1
PORT=8100
```
从仓库根目录启动:
```bash
uv run s02-5-backend-runtime/main.py
```
然后访问 `http://127.0.0.1:8100`。OpenAPI 文档位于
`http://127.0.0.1:8100/docs`。
也可以从章节目录启动:
```bash
cd s02-5-backend-runtime
uv run --project .. python main.py
```
## 验证
```bash
cd s02-5-backend-runtime
uv run --project .. python -m unittest discover -s tests -v
uv run --project .. --with mypy mypy --strict --explicit-package-bases .
uvx ruff check .
```
测试覆盖工具注册与路径边界、OpenAI tool call 配对、服务端历史、会话隔离、
同会话串行、跨会话并行、失败不提交、HTTP 契约、结构化错误和客户端生命周期。
## 本章不实现
- s03 的 `ALLOW / ASK / DENY` 权限与审批恢复
- 用户认证和 Cookie Session
- Redis、数据库或跨进程锁
- s09 的跨会话 Memory
- 流式响应、后台 Run 和任务队列
from __future__ import annotations
import json
import unittest
from typing import Any
import httpx2 as httpx
from agent.loop import ToolCallingAgent
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
from tooling.contract import ToolDefinition
from tooling.registry import ToolRegistry
from tooling.runtime import ToolRuntime
def response(message: dict[str, Any], finish_reason: str = "stop") -> httpx.Response:
return httpx.Response(
200,
json={
"id": "test-completion",
"object": "chat.completion",
"created": 0,
"model": "test-model",
"choices": [
{"index": 0, "message": message, "finish_reason": finish_reason}
],
},
)
class ToolCallingAgentTests(unittest.IsolatedAsyncioTestCase):
async def test_tool_calls_are_executed_and_paired_in_history(self) -> None:
requests: list[dict[str, Any]] = []
executed: list[str] = []
async def echo(arguments: dict[str, Any]) -> str:
value = str(arguments["text"])
executed.append(value)
return value
runtime = ToolRuntime(
ToolRegistry(
[
ToolDefinition(
"echo",
"Echo text",
{
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
echo,
)
]
)
)
def respond(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
requests.append(body)
if len(requests) == 1:
return response(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {
"name": "echo",
"arguments": '{"text":"one"}',
},
},
{
"id": "call-2",
"type": "function",
"function": {
"name": "missing",
"arguments": "{}",
},
},
],
},
"tool_calls",
)
return response({"role": "assistant", "content": "done"})
history: list[ChatCompletionMessageParam] = [
{"role": "user", "content": "Run tools"}
]
async with AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)),
) as client:
agent = ToolCallingAgent(
runtime,
client=client,
model="test-model",
system_prompt="system",
)
result = await agent.run(history)
self.assertEqual(result.answer, "done")
self.assertEqual(executed, ["one"])
self.assertEqual(history, [{"role": "user", "content": "Run tools"}])
self.assertEqual(requests[0]["messages"][0]["role"], "system")
second_messages = requests[1]["messages"]
self.assertEqual(
[message["role"] for message in second_messages],
["system", "user", "assistant", "tool", "tool"],
)
self.assertEqual(second_messages[3]["tool_call_id"], "call-1")
self.assertEqual(second_messages[4]["tool_call_id"], "call-2")
self.assertTrue(second_messages[4]["content"].startswith("Error: Unknown tool"))
self.assertEqual(
[message["role"] for message in result.messages],
["user", "assistant", "tool", "tool", "assistant"],
)
async def test_empty_registry_omits_tool_parameters(self) -> None:
requests: list[dict[str, Any]] = []
def respond(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content))
return response({"role": "assistant", "content": "hello"})
async with AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)),
) as client:
agent = ToolCallingAgent(
ToolRuntime(ToolRegistry([])),
client=client,
model="test-model",
system_prompt="system",
)
result = await agent.run([])
self.assertEqual(result.answer, "hello")
self.assertNotIn("tools", requests[0])
self.assertNotIn("tool_choice", requests[0])
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import json
import unittest
from typing import Any
from unittest.mock import patch
import httpx2 as httpx
from api import app as api_app
from fastapi.testclient import TestClient
from openai import AsyncOpenAI
def completion(content: str) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "test-completion",
"object": "chat.completion",
"created": 0,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
},
)
class BackendApiTests(unittest.TestCase):
def test_conversation_lifecycle_uses_server_owned_history(self) -> None:
model_requests: list[dict[str, Any]] = []
def respond(request: httpx.Request) -> httpx.Response:
model_requests.append(json.loads(request.content))
return completion(f"reply-{len(model_requests)}")
model_client = AsyncOpenAI(
api_key="test-key",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond)),
)
with (
patch.object(api_app, "create_client", return_value=model_client),
TestClient(api_app.app) as http,
):
self.assertEqual(http.get("/health").json(), {"status": "ok"})
created = http.post("/api/conversations")
self.assertEqual(created.status_code, 201)
conversation_id = created.json()["conversation_id"]
first = http.post(
f"/api/conversations/{conversation_id}/runs",
json={"input": "first"},
)
second = http.post(
f"/api/conversations/{conversation_id}/runs",
json={"input": "second"},
)
history = http.get(f"/api/conversations/{conversation_id}")
self.assertEqual(first.status_code, 200)
self.assertEqual(first.json()["status"], "completed")
self.assertEqual(first.json()["conversation_id"], conversation_id)
self.assertEqual(
first.json()["message"], {"role": "assistant", "content": "reply-1"}
)
self.assertEqual(second.json()["message"]["content"], "reply-2")
self.assertEqual(
[turn["input"] for turn in history.json()["turns"]],
["first", "second"],
)
separate = http.post("/api/conversations").json()["conversation_id"]
http.post(
f"/api/conversations/{separate}/runs",
json={"input": "isolated"},
)
self.assertTrue(model_client.is_closed())
self.assertEqual(
[message["role"] for message in model_requests[0]["messages"]],
["system", "user"],
)
self.assertEqual(
[message["role"] for message in model_requests[1]["messages"]],
["system", "user", "assistant", "user"],
)
self.assertEqual(
[message["role"] for message in model_requests[2]["messages"]],
["system", "user"],
)
def test_unknown_conversation_has_structured_error(self) -> None:
model_client = AsyncOpenAI(api_key="test-key")
missing_id = "00000000-0000-0000-0000-000000000099"
with (
patch.object(api_app, "create_client", return_value=model_client),
TestClient(api_app.app) as http,
):
response = http.post(
f"/api/conversations/{missing_id}/runs",
json={"input": "hello"},
)
self.assertEqual(response.status_code, 404)
self.assertEqual(
response.json()["error"]["code"],
"conversation_not_found",
)
def test_request_rejects_blank_and_client_owned_history(self) -> None:
model_client = AsyncOpenAI(api_key="test-key")
with (
patch.object(api_app, "create_client", return_value=model_client),
TestClient(api_app.app) as http,
):
conversation_id = http.post("/api/conversations").json()["conversation_id"]
blank = http.post(
f"/api/conversations/{conversation_id}/runs",
json={"input": " "},
)
old_contract = http.post(
f"/api/conversations/{conversation_id}/runs",
json={"messages": [{"role": "user", "content": "client history"}]},
)
self.assertEqual(blank.status_code, 422)
self.assertEqual(old_contract.status_code, 422)
def test_run_schema_reserves_status_as_literal_discriminator(self) -> None:
schema = api_app.app.openapi()
run_schema = schema["components"]["schemas"]["CompletedRunResponse"]
self.assertEqual(
run_schema["properties"]["status"]["const"],
"completed",
)
self.assertIn(
"404",
schema["paths"]["/api/conversations/{conversation_id}/runs"]["post"][
"responses"
],
)
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import asyncio
import unittest
from collections.abc import Sequence
from uuid import UUID
from agent.contract import AgentResult
from agent.service import ConversationService
from conversations.contract import ConversationNotFoundError
from conversations.memory import InMemoryConversationStore
from openai.types.chat import ChatCompletionMessageParam
CONVERSATION_IDS = (
UUID("00000000-0000-0000-0000-000000000001"),
UUID("00000000-0000-0000-0000-000000000002"),
)
RUN_IDS = (
UUID("10000000-0000-0000-0000-000000000001"),
UUID("10000000-0000-0000-0000-000000000002"),
UUID("10000000-0000-0000-0000-000000000003"),
)
def result_with_answer(
history: Sequence[ChatCompletionMessageParam],
answer: str,
) -> AgentResult:
messages = list(history)
messages.append({"role": "assistant", "content": answer})
return AgentResult(answer=answer, messages=tuple(messages))
class RecordingRunner:
def __init__(self) -> None:
self.histories: list[tuple[ChatCompletionMessageParam, ...]] = []
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
self.histories.append(tuple(history))
return result_with_answer(history, f"answer-{len(self.histories)}")
class SerialRunner:
def __init__(self) -> None:
self.first_started = asyncio.Event()
self.release_first = asyncio.Event()
self.active = 0
self.max_active = 0
self.histories: list[tuple[ChatCompletionMessageParam, ...]] = []
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
index = len(self.histories)
self.histories.append(tuple(history))
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
if index == 0:
self.first_started.set()
await self.release_first.wait()
return result_with_answer(history, f"answer-{index + 1}")
finally:
self.active -= 1
class CrossConversationRunner:
def __init__(self) -> None:
self.first_started = asyncio.Event()
self.second_started = asyncio.Event()
self.release = asyncio.Event()
self.active = 0
self.max_active = 0
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
self.active += 1
self.max_active = max(self.max_active, self.active)
if self.active == 1:
self.first_started.set()
else:
self.second_started.set()
try:
await self.release.wait()
return result_with_answer(history, "done")
finally:
self.active -= 1
class FlakyRunner:
def __init__(self) -> None:
self.calls = 0
async def run(
self,
history: Sequence[ChatCompletionMessageParam],
) -> AgentResult:
self.calls += 1
if self.calls == 2:
first_message = history[0]
first_message["content"] = "corrupted"
raise RuntimeError("model failed")
return result_with_answer(history, "committed")
class ConversationServiceTests(unittest.IsolatedAsyncioTestCase):
async def test_server_history_and_conversations_are_isolated(self) -> None:
conversation_ids = iter(CONVERSATION_IDS)
run_ids = iter(RUN_IDS)
store = InMemoryConversationStore(lambda: next(conversation_ids))
runner = RecordingRunner()
service = ConversationService(
store,
runner,
run_id_factory=lambda: next(run_ids),
)
first = await service.create()
second = await service.create()
await service.run(first.conversation_id, "first")
await service.run(first.conversation_id, "second")
await service.run(second.conversation_id, "isolated")
self.assertEqual(
[message["role"] for message in runner.histories[1]],
["user", "assistant", "user"],
)
self.assertEqual(runner.histories[1][0]["content"], "first")
self.assertEqual(
[message["role"] for message in runner.histories[2]],
["user"],
)
first_snapshot = await service.get(first.conversation_id)
second_snapshot = await service.get(second.conversation_id)
self.assertEqual(
[turn.user_input for turn in first_snapshot.turns], ["first", "second"]
)
self.assertEqual(
[turn.user_input for turn in second_snapshot.turns], ["isolated"]
)
async def test_missing_conversation_is_rejected(self) -> None:
service = ConversationService(InMemoryConversationStore(), RecordingRunner())
missing = UUID("00000000-0000-0000-0000-000000000099")
with self.assertRaises(ConversationNotFoundError):
await service.get(missing)
with self.assertRaises(ConversationNotFoundError):
await service.run(missing, "hello")
async def test_same_conversation_runs_are_serialized(self) -> None:
runner = SerialRunner()
service = ConversationService(InMemoryConversationStore(), runner)
conversation = await service.create()
first = asyncio.create_task(service.run(conversation.conversation_id, "one"))
await runner.first_started.wait()
second = asyncio.create_task(service.run(conversation.conversation_id, "two"))
await asyncio.sleep(0)
self.assertFalse(second.done())
self.assertEqual(runner.max_active, 1)
runner.release_first.set()
await asyncio.gather(first, second)
self.assertEqual(runner.max_active, 1)
self.assertEqual(
[message["role"] for message in runner.histories[1]],
["user", "assistant", "user"],
)
snapshot = await service.get(conversation.conversation_id)
self.assertEqual([turn.user_input for turn in snapshot.turns], ["one", "two"])
async def test_different_conversations_can_run_concurrently(self) -> None:
runner = CrossConversationRunner()
service = ConversationService(InMemoryConversationStore(), runner)
first_conversation = await service.create()
second_conversation = await service.create()
first = asyncio.create_task(
service.run(first_conversation.conversation_id, "one")
)
await runner.first_started.wait()
second = asyncio.create_task(
service.run(second_conversation.conversation_id, "two")
)
await asyncio.wait_for(runner.second_started.wait(), timeout=1)
self.assertEqual(runner.max_active, 2)
runner.release.set()
await asyncio.gather(first, second)
async def test_failed_run_does_not_commit_partial_history(self) -> None:
runner = FlakyRunner()
service = ConversationService(InMemoryConversationStore(), runner)
conversation = await service.create()
await service.run(conversation.conversation_id, "first")
before = await service.get(conversation.conversation_id)
with self.assertRaisesRegex(RuntimeError, "model failed"):
await service.run(conversation.conversation_id, "second")
after = await service.get(conversation.conversation_id)
self.assertEqual(after, before)
self.assertEqual(after.messages[0]["content"], "first")
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import json
import subprocess
import sys
import unittest
from pathlib import Path
from typing import Any
from tooling.contract import ToolDefinition
from tooling.registry import ToolRegistry
from tooling.runtime import ToolRuntime
class ToolingTests(unittest.IsolatedAsyncioTestCase):
async def test_registries_can_use_different_handlers_for_the_same_name(
self,
) -> None:
async def first(_arguments: dict[str, Any]) -> str:
return "first"
async def second(_arguments: dict[str, Any]) -> str:
return "second"
first_tool = ToolDefinition("example", "First tool", {}, first)
second_tool = ToolDefinition("example", "Second tool", {}, second)
one = ToolRuntime(ToolRegistry([first_tool]))
two = ToolRuntime(ToolRegistry([second_tool]))
self.assertEqual(await one.execute("example", "{}"), "first")
self.assertEqual(await two.execute("example", "{}"), "second")
self.assertEqual(one.schemas()[0]["function"]["description"], "First tool")
self.assertEqual(two.schemas()[0]["function"]["description"], "Second tool")
async def test_unregistered_tool_is_neither_exposed_nor_executed(self) -> None:
calls: list[str] = []
async def handler(_arguments: dict[str, Any]) -> str:
calls.append("executed")
return "ok"
declared = ToolDefinition("declared", "Not enabled", {}, handler)
runtime = ToolRuntime(ToolRegistry([]))
result = await runtime.execute(declared.name, "{}")
self.assertEqual(runtime.schemas(), [])
self.assertTrue(result.startswith("Error: Unknown tool"))
self.assertEqual(calls, [])
async def test_invalid_arguments_do_not_reach_handler(self) -> None:
received: list[dict[str, Any]] = []
async def handler(arguments: dict[str, Any]) -> str:
received.append(arguments)
return json.dumps(arguments, ensure_ascii=False)
runtime = ToolRuntime(
ToolRegistry([ToolDefinition("echo", "Echo", {}, handler)])
)
for raw in ("{", "[]", "null", "true", "123", '"text"'):
with self.subTest(arguments=raw):
self.assertTrue(
(await runtime.execute("echo", raw)).startswith("Error:")
)
self.assertEqual(received, [])
result = await runtime.execute("echo", '{"text":"你好"}')
self.assertEqual(json.loads(result), {"text": "你好"})
self.assertEqual(received, [{"text": "你好"}])
def test_duplicate_name_is_rejected(self) -> None:
async def handler(_arguments: dict[str, Any]) -> str:
return "ok"
tool = ToolDefinition("echo", "Echo", {}, handler)
with self.assertRaisesRegex(ValueError, "Duplicate tool name: echo"):
ToolRegistry([tool, tool])
def test_schema_changes_do_not_mutate_tool_definitions(self) -> None:
async def handler(_arguments: dict[str, Any]) -> str:
return "ok"
tool = ToolDefinition(
"echo",
"Echo",
{"type": "object", "properties": {"text": {"type": "string"}}},
handler,
)
registry = ToolRegistry([tool])
schema = registry.schemas()[0]["function"]["parameters"]
schema["type"] = "array"
self.assertEqual(tool.parameters["type"], "object")
self.assertEqual(
registry.schemas()[0]["function"]["parameters"]["type"], "object"
)
self.assertNotIn("handler", registry.schemas()[0]["function"])
async def test_programming_errors_are_not_swallowed(self) -> None:
async def broken(_arguments: dict[str, Any]) -> str:
raise RuntimeError("handler bug")
runtime = ToolRuntime(
ToolRegistry([ToolDefinition("broken", "Broken", {}, broken)])
)
with self.assertRaisesRegex(RuntimeError, "handler bug"):
await runtime.execute("broken", "{}")
def test_importing_mechanisms_does_not_load_concrete_tools(self) -> None:
# 独立解释器避免其他测试已导入具体工具,验证真实的包导入边界。
result = subprocess.run(
[
sys.executable,
"-c",
(
"import sys\n"
"import tooling.contract\n"
"import tooling.registry\n"
"import tooling.runtime\n"
"import tools\n"
"assert 'tools.shell' not in sys.modules\n"
"assert 'tools.filesystem' not in sys.modules\n"
"assert 'bootstrap' not in sys.modules\n"
),
],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from bootstrap import build_runtime
from workspace import WORKDIR
class BuiltinToolTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.runtime = build_runtime()
def test_all_five_tools_are_exposed(self) -> None:
names = {tool["function"]["name"] for tool in self.runtime.schemas()}
self.assertEqual(
names,
{"bash", "read_file", "write_file", "edit_file", "glob"},
)
def test_workdir_is_the_chapter_directory(self) -> None:
chapter_dir = Path(__file__).resolve().parents[1]
self.assertEqual(WORKDIR, chapter_dir)
async def test_unknown_tool_and_invalid_json_are_rejected(self) -> None:
unknown = await self.runtime.execute("unknown", "{}")
invalid_json = await self.runtime.execute("bash", "not-json")
self.assertTrue(unknown.startswith("Error: Unknown tool"))
self.assertTrue(invalid_json.startswith("Error: Invalid arguments"))
async def test_file_tool_flow(self) -> None:
with tempfile.TemporaryDirectory(dir=WORKDIR) as temp_dir:
relative_dir = Path(temp_dir).relative_to(WORKDIR)
relative_file = relative_dir / "nested" / "demo.txt"
write_result = await self.call_tool(
"write_file",
{"path": str(relative_file), "content": "one\ntwo\nthree\n"},
)
self.assertTrue(write_result.startswith("Wrote"))
read_result = await self.call_tool(
"read_file",
{"path": str(relative_file), "limit": 2},
)
self.assertEqual(read_result, "one\ntwo\n... (1 more lines)")
edit_result = await self.call_tool(
"edit_file",
{
"path": str(relative_file),
"old_text": "two",
"new_text": "TWO",
},
)
self.assertTrue(edit_result.startswith("Edited"))
glob_result = await self.call_tool(
"glob",
{"pattern": f"{relative_dir}/**/*.txt"},
)
self.assertIn(str(relative_file), glob_result)
async def test_file_tools_cannot_escape_workdir(self) -> None:
result = await self.call_tool(
"read_file",
{"path": "../outside.txt"},
)
self.assertTrue(result.startswith("Error: Path escapes workspace"))
async def test_symlink_cannot_escape_workdir(self) -> None:
with (
tempfile.TemporaryDirectory() as outside,
tempfile.TemporaryDirectory(dir=WORKDIR) as inside,
):
target = Path(outside) / "private.txt"
target.write_text("outside content", encoding="utf-8")
link = Path(inside) / "link.txt"
link.symlink_to(target)
result = await self.call_tool(
"read_file", {"path": str(link.relative_to(WORKDIR))}
)
self.assertTrue(result.startswith("Error: Path escapes workspace"))
async def test_bash_uses_chapter_workdir(self) -> None:
result = await self.call_tool("bash", {"command": "pwd"})
self.assertEqual(Path(result).resolve(), WORKDIR)
async def call_tool(self, name: str, arguments: dict[str, object]) -> str:
return await self.runtime.execute(name, json.dumps(arguments))
if __name__ == "__main__":
unittest.main()
"""通用工具机制;导入包不会加载具体工具或创建注册表。"""
"""工具契约:统一描述能力,不决定应用是否启用该能力。"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
# 运行时负责 JSON 解码,具体 handler 负责检查字段并返回文本结果。
ToolHandler = Callable[[dict[str, Any]], Awaitable[str]]
@dataclass(frozen=True)
class ToolDefinition:
"""Schema 与 handler 成对声明;handler 只在本地使用,不发送给模型。"""
name: str
description: str
parameters: dict[str, Any]
handler: ToolHandler
"""通用注册表:接收显式工具清单,负责名称索引和 OpenAI Schema。"""
from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from openai.types.chat import ChatCompletionToolParam
from .contract import ToolDefinition
class ToolRegistry:
"""每个实例拥有独立索引;不导入具体工具,也不执行工具。"""
def __init__(self, definitions: Iterable[ToolDefinition]) -> None:
self._tools: dict[str, ToolDefinition] = {}
for tool in definitions:
if tool.name in self._tools:
raise ValueError(f"Duplicate tool name: {tool.name}")
self._tools[tool.name] = tool
def get(self, name: str) -> ToolDefinition | None:
return self._tools.get(name)
def schemas(self) -> list[ChatCompletionToolParam]:
"""从同一索引生成模型可见清单,避免声明与执行清单不一致。"""
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
# 返回独立数据,调用方修改请求不会改变工具的声明。
"parameters": deepcopy(tool.parameters),
},
}
for tool in self._tools.values()
]
"""工具运行时:查找工具、解析参数并调用 handler。"""
from __future__ import annotations
import json
from openai.types.chat import ChatCompletionToolParam
from .registry import ToolRegistry
class ToolRuntime:
"""只依赖传入的注册表;s02 不包含权限、审批或 hooks。"""
def __init__(self, registry: ToolRegistry) -> None:
self._registry = registry
def schemas(self) -> list[ChatCompletionToolParam]:
return self._registry.schemas()
async def execute(self, name: str, raw_arguments: str) -> str:
tool = self._registry.get(name)
if tool is None:
return f"Error: Unknown tool: {name}"
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError as exc:
return f"Error: Invalid arguments: {exc}"
if not isinstance(arguments, dict):
return "Error: arguments must be an object"
# 字段校验由具体工具负责;编程错误继续抛出,不伪装成工具结果。
return await tool.handler(arguments)
"""具体工具实现;声明工具不等于注册,启用清单由 bootstrap.py 决定。"""
"""文件系统工具:read_file、write_file、edit_file 和 glob。"""
from __future__ import annotations
import asyncio
import glob as glob_module
from typing import Any
from tooling.contract import ToolDefinition
from workspace import WORKDIR, safe_path
def _read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text(encoding="utf-8").splitlines()
if limit is not None and limit < len(lines):
omitted = len(lines) - limit
lines = lines[:limit] + [f"... ({omitted} more lines)"]
return "\n".join(lines)
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def read_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
limit = arguments.get("limit")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if limit is not None and (
not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0
):
return "Error: limit must be a positive integer"
return await asyncio.to_thread(_read, path, limit)
def _write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return f"Wrote {len(content)} characters to {path}"
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def write_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
content = arguments.get("content")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if not isinstance(content, str):
return "Error: content must be a string"
return await asyncio.to_thread(_write, path, content)
def _edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text(encoding="utf-8")
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(
text.replace(old_text, new_text, 1),
encoding="utf-8",
)
return f"Edited {path}"
except (OSError, UnicodeError, ValueError) as exc:
return f"Error: {exc}"
async def edit_file(arguments: dict[str, Any]) -> str:
path = arguments.get("path")
old_text = arguments.get("old_text")
new_text = arguments.get("new_text")
if not isinstance(path, str) or not path:
return "Error: path must be a non-empty string"
if not isinstance(old_text, str) or not old_text:
return "Error: old_text must be a non-empty string"
if not isinstance(new_text, str):
return "Error: new_text must be a string"
return await asyncio.to_thread(_edit, path, old_text, new_text)
def _glob(pattern: str) -> str:
try:
matches = sorted(
{
match
for match in glob_module.glob(
pattern,
root_dir=WORKDIR,
recursive=True,
)
if (WORKDIR / match).resolve().is_relative_to(WORKDIR)
}
)
shown = matches[:200]
if len(matches) > 200:
shown.append("... (more matches omitted; narrow the pattern)")
return "\n".join(shown) if shown else "(no matches)"
except (OSError, ValueError) as exc:
return f"Error: {exc}"
async def glob(arguments: dict[str, Any]) -> str:
pattern = arguments.get("pattern")
if not isinstance(pattern, str) or not pattern:
return "Error: pattern must be a non-empty string"
return await asyncio.to_thread(_glob, pattern)
READ_FILE = ToolDefinition(
name="read_file",
description="Read UTF-8 file contents.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer", "minimum": 1},
},
"required": ["path"],
"additionalProperties": False,
},
handler=read_file,
)
WRITE_FILE = ToolDefinition(
name="write_file",
description="Write UTF-8 content to a file.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
handler=write_file,
)
EDIT_FILE = ToolDefinition(
name="edit_file",
description="Replace the first exact text occurrence in a file.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string", "minLength": 1},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
"additionalProperties": False,
},
handler=edit_file,
)
GLOB = ToolDefinition(
name="glob",
description="Find files matching a glob pattern; ** matches recursively.",
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string"},
},
"required": ["pattern"],
"additionalProperties": False,
},
handler=glob,
)
"""bash 工具。"""
from __future__ import annotations
import asyncio
import subprocess
from typing import Any
from tooling.contract import ToolDefinition
from workspace import WORKDIR
def _run(command: str) -> str:
"""同步执行命令;字符串黑名单仅是 s02 教学占位。"""
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(value in command for value in dangerous):
return "Error: Dangerous command blocked"
try:
result = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
errors="replace",
timeout=120,
check=False,
)
output = (result.stdout + result.stderr).strip()
return output[:50_000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except OSError as exc:
return f"Error: {exc}"
async def handler(arguments: dict[str, Any]) -> str:
command = arguments.get("command")
if not isinstance(command, str):
return "Error: command must be a string"
return await asyncio.to_thread(_run, command)
BASH = ToolDefinition(
name="bash",
description="Run a shell command.",
parameters={
"type": "object",
"properties": {
"command": {"type": "string"},
},
"required": ["command"],
"additionalProperties": False,
},
handler=handler,
)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agent Chat</title>
<style>
:root {
--ink-950: #171a1f;
--ink-900: #20242a;
--ink-800: #2a3037;
--ink-500: #74808c;
--line: #d8ddd9;
--line-strong: #bcc5c0;
--canvas: #f1f3f0;
--surface: #fbfcfa;
--surface-muted: #f5f7f4;
--accent: #168371;
--accent-strong: #0d6c5d;
--danger: #b83a32;
font-family: "Avenir Next", "Noto Sans SC", "PingFang SC", sans-serif;
color: var(--ink-900);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background:
linear-gradient(rgba(23, 26, 31, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(23, 26, 31, 0.025) 1px, transparent 1px),
var(--canvas);
background-size: 32px 32px;
}
button,
textarea {
font: inherit;
letter-spacing: 0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.chat-room {
display: grid;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.chat-header {
position: relative;
z-index: 1;
min-height: 66px;
border-bottom: 1px solid #343a41;
background: var(--ink-950);
color: #f2f5f2;
}
.header-inner {
display: flex;
width: min(960px, calc(100% - 40px));
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
margin: 0 auto;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 1px solid #485159;
border-radius: 4px;
background: #22272d;
color: #62c4ae;
font: 800 0.85rem "SFMono-Regular", Consolas, monospace;
}
.brand h1 {
margin: 0;
font-size: 1rem;
}
.connection-state {
display: inline-flex;
align-items: center;
gap: 8px;
color: #e28b84;
font: 0.72rem "SFMono-Regular", Consolas, monospace;
}
.connection-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ca554d;
box-shadow: 0 0 0 3px rgba(202, 85, 77, 0.13);
}
.connection-state.online {
color: #8dcabd;
}
.connection-state.online .connection-dot {
background: #53b69f;
box-shadow: 0 0 0 3px rgba(83, 182, 159, 0.13);
}
.chat-workspace {
display: grid;
width: min(960px, 100%);
height: 100%;
min-height: 0;
grid-template-rows: minmax(0, 1fr) auto;
margin: 0 auto;
overflow: hidden;
border-inline: 1px solid var(--line);
background: var(--surface);
}
.messages {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 18px;
background:
linear-gradient(rgba(23, 26, 31, 0.025) 1px, transparent 1px),
var(--surface-muted);
background-size: 100% 28px;
scrollbar-width: none;
}
.messages::-webkit-scrollbar,
textarea::-webkit-scrollbar {
display: none;
}
.message {
display: flex;
max-width: 78%;
flex: 0 0 auto;
flex-direction: column;
align-self: flex-start;
gap: 3px;
}
.message.user {
align-items: flex-end;
align-self: flex-end;
}
.message-meta {
padding: 0 3px;
color: var(--ink-500);
font: 0.68rem "SFMono-Regular", Consolas, monospace;
}
.message-bubble {
max-width: 100%;
padding: 9px 11px;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border-radius: 4px;
background: #fff;
color: var(--ink-800);
font-size: 0.9rem;
line-height: 1.6;
}
.message.user .message-bubble {
border-color: var(--accent-strong);
background: var(--accent);
color: #fff;
white-space: pre-wrap;
}
.message.error .message-bubble {
border-color: var(--danger);
color: var(--danger);
white-space: pre-wrap;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p,
.markdown ul,
.markdown ol,
.markdown blockquote,
.markdown pre,
.markdown table {
margin: 0 0 10px;
}
.markdown h1,
.markdown h2,
.markdown h3 {
margin: 16px 0 8px;
line-height: 1.35;
}
.markdown h1 { font-size: 1.18rem; }
.markdown h2 { font-size: 1.08rem; }
.markdown h3 { font-size: 1rem; }
.markdown ul,
.markdown ol {
padding-left: 22px;
}
.markdown blockquote {
padding-left: 12px;
border-left: 3px solid var(--line-strong);
color: var(--ink-500);
}
.markdown code {
padding: 2px 4px;
border-radius: 3px;
background: var(--surface-muted);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.84em;
}
.markdown pre {
max-width: 100%;
padding: 12px;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--ink-950);
color: #eef3ef;
}
.markdown pre code {
padding: 0;
background: transparent;
color: inherit;
}
.markdown a {
color: var(--accent-strong);
text-decoration: underline;
text-underline-offset: 2px;
}
.markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.markdown th,
.markdown td {
padding: 6px 8px;
border: 1px solid var(--line);
text-align: left;
}
.chat-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px 42px;
align-items: end;
gap: 9px;
padding: 12px 14px max(12px, env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--surface);
}
textarea {
width: 100%;
min-width: 0;
height: 84px;
resize: none;
padding: 10px 11px;
overflow-y: auto;
border: 1px solid var(--line-strong);
border-radius: 4px;
outline: none;
background: #fff;
color: var(--ink-900);
line-height: 1.4;
scrollbar-width: none;
}
textarea:focus {
border-color: var(--accent);
}
.send-button,
.clear-button {
display: inline-grid;
width: 42px;
height: 42px;
padding: 0;
place-items: center;
border-radius: 4px;
cursor: pointer;
}
.send-button svg,
.clear-button svg {
width: 18px;
height: 18px;
stroke-width: 2;
}
.send-button {
border: 1px solid var(--accent-strong);
background: var(--accent);
color: #fff;
}
.send-button:hover:not(:disabled) {
background: var(--accent-strong);
}
.clear-button {
border: 1px solid var(--line-strong);
background: var(--surface);
color: var(--ink-800);
}
.clear-button:hover:not(:disabled) {
border-color: var(--ink-500);
background: var(--surface-muted);
}
@media (max-width: 700px) {
.header-inner {
width: calc(100% - 24px);
}
.chat-workspace {
border: 0;
}
.messages {
padding: 14px 12px;
}
.message {
max-width: 88%;
}
.chat-composer {
grid-template-columns: minmax(0, 1fr) 42px 42px;
padding-inline: 10px;
}
.send-button,
.clear-button {
width: 42px;
}
}
</style>
</head>
<body>
<div class="chat-room">
<header class="chat-header">
<div class="header-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">Q/</span>
<h1>Agent Chat</h1>
</div>
<div id="connectionState" class="connection-state" role="status">
<span class="connection-dot" aria-hidden="true"></span>
<span id="connectionText">离线</span>
</div>
</div>
</header>
<main class="chat-workspace">
<section
id="messages"
class="messages"
aria-label="消息"
aria-live="polite"
></section>
<form id="composer" class="chat-composer">
<textarea
id="textInput"
aria-label="消息"
placeholder="输入消息"
></textarea>
<button
id="sendButton"
class="send-button"
type="submit"
aria-label="发送"
title="发送"
disabled
>
<i data-lucide="send-horizontal" aria-hidden="true"></i>
</button>
<button
id="newConversationButton"
class="clear-button"
type="button"
aria-label="新建对话"
title="新建对话"
disabled
>
<i data-lucide="square-pen" aria-hidden="true"></i>
</button>
</form>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.468.0/dist/umd/lucide.min.js"></script>
<script>
const CONVERSATIONS_URL = "/api/conversations";
const HEALTH_URL = "/health";
const CONVERSATION_KEY = "agent-chat.conversation-id";
const HEALTH_INTERVAL_MS = 5000;
const HEALTH_TIMEOUT_MS = 3000;
const messagesElement = document.getElementById("messages");
const composer = document.getElementById("composer");
const textInput = document.getElementById("textInput");
const sendButton = document.getElementById("sendButton");
const newConversationButton =
document.getElementById("newConversationButton");
const connectionState = document.getElementById("connectionState");
const connectionText = document.getElementById("connectionText");
let sending = false;
let resetting = false;
let checkingHealth = false;
let activeRequest = null;
let displayVersion = 0;
let conversationId = null;
marked.setOptions({
gfm: true,
breaks: true,
});
lucide.createIcons();
function setOnline(value) {
connectionState.classList.toggle("online", value);
connectionText.textContent = value ? "在线" : "离线";
}
function syncControls() {
sendButton.disabled =
sending ||
resetting ||
!conversationId ||
!textInput.value.trim();
newConversationButton.disabled = resetting;
}
function scrollToBottom() {
messagesElement.scrollTop = messagesElement.scrollHeight;
}
function renderMarkdown(element, source) {
const html = marked.parse(source);
element.classList.add("markdown");
element.innerHTML = DOMPurify.sanitize(html);
element.querySelectorAll("a").forEach((link) => {
link.target = "_blank";
link.rel = "noopener noreferrer";
});
}
function appendMessage(role, content, options = {}) {
const wrapper = document.createElement("article");
wrapper.className =
`message ${role}${options.error ? " error" : ""}`;
const meta = document.createElement("div");
meta.className = "message-meta";
const time = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
meta.textContent =
`${role === "user" ? "你" : "Agent"} · ${time}`;
const bubble = document.createElement("div");
bubble.className = "message-bubble";
if (role === "assistant" && options.markdown) {
renderMarkdown(bubble, content);
} else {
bubble.textContent = content;
}
wrapper.append(meta, bubble);
messagesElement.append(wrapper);
scrollToBottom();
syncControls();
return { wrapper, bubble };
}
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
let data = null;
try {
data = await response.json();
} catch {
// 非 JSON 错误仍按 HTTP 状态返回统一的人类可读提示。
}
if (!response.ok) {
const message =
data?.error?.message ||
data?.detail?.[0]?.msg ||
`请求失败:HTTP ${response.status}`;
const error = new Error(message);
error.status = response.status;
throw error;
}
return data;
}
async function checkHealth() {
if (checkingHealth) {
return;
}
checkingHealth = true;
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
HEALTH_TIMEOUT_MS,
);
try {
const response = await fetch(HEALTH_URL, {
method: "GET",
cache: "no-store",
signal: controller.signal,
});
setOnline(response.ok);
} catch {
setOnline(false);
} finally {
window.clearTimeout(timeout);
checkingHealth = false;
}
}
function renderConversation(conversation) {
messagesElement.replaceChildren();
for (const turn of conversation.turns || []) {
appendMessage("user", turn.input);
appendMessage("assistant", turn.message.content, {
markdown: true,
});
}
}
async function createConversation() {
const data = await requestJson(CONVERSATIONS_URL, {
method: "POST",
});
if (typeof data?.conversation_id !== "string") {
throw new Error("接口没有返回有效的 conversation_id");
}
conversationId = data.conversation_id;
localStorage.setItem(CONVERSATION_KEY, conversationId);
syncControls();
}
async function restoreConversation() {
const savedId = localStorage.getItem(CONVERSATION_KEY);
if (!savedId) {
await createConversation();
return;
}
try {
const conversation = await requestJson(
`${CONVERSATIONS_URL}/${encodeURIComponent(savedId)}`,
{ cache: "no-store" },
);
conversationId = conversation.conversation_id;
renderConversation(conversation);
syncControls();
} catch (error) {
if (error.status !== 404 && error.status !== 422) {
throw error;
}
localStorage.removeItem(CONVERSATION_KEY);
await createConversation();
}
}
async function startNewConversation() {
displayVersion += 1;
if (activeRequest) {
activeRequest.abort();
activeRequest = null;
}
sending = false;
resetting = true;
conversationId = null;
localStorage.removeItem(CONVERSATION_KEY);
messagesElement.replaceChildren();
syncControls();
try {
await createConversation();
} catch (error) {
appendMessage(
"assistant",
error instanceof Error ? error.message : "创建对话失败",
{ error: true },
);
checkHealth();
} finally {
resetting = false;
syncControls();
textInput.focus();
}
}
async function sendMessage() {
const content = textInput.value.trim();
if (!content || sending || resetting || !conversationId) {
return;
}
const requestVersion = displayVersion;
const requestConversationId = conversationId;
const controller = new AbortController();
activeRequest = controller;
sending = true;
const userMessage = appendMessage("user", content);
textInput.value = "";
syncControls();
const pending = appendMessage("assistant", "正在思考…");
try {
const data = await requestJson(
`${CONVERSATIONS_URL}/${encodeURIComponent(requestConversationId)}/runs`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ input: content }),
signal: controller.signal,
},
);
if (requestVersion !== displayVersion) {
return;
}
if (
data?.status !== "completed" ||
typeof data?.message?.content !== "string"
) {
throw new Error("接口没有返回 completed Run");
}
renderMarkdown(pending.bubble, data.message.content);
} catch (error) {
if (
error.name === "AbortError" ||
requestVersion !== displayVersion
) {
return;
}
userMessage.wrapper.remove();
pending.wrapper.remove();
appendMessage(
"assistant",
error instanceof Error ? error.message : "请求失败",
{ error: true },
);
checkHealth();
} finally {
if (requestVersion === displayVersion) {
sending = false;
activeRequest = null;
syncControls();
scrollToBottom();
textInput.focus();
}
}
}
textInput.addEventListener("input", syncControls);
textInput.addEventListener("keydown", (event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.isComposing
) {
event.preventDefault();
sendMessage();
}
});
composer.addEventListener("submit", (event) => {
event.preventDefault();
sendMessage();
});
newConversationButton.addEventListener(
"click",
startNewConversation,
);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
checkHealth();
}
});
async function initialize() {
resetting = true;
syncControls();
checkHealth();
try {
await restoreConversation();
} catch (error) {
appendMessage(
"assistant",
error instanceof Error ? error.message : "初始化失败",
{ error: true },
);
} finally {
resetting = false;
syncControls();
textInput.focus();
}
}
initialize();
window.setInterval(checkHealth, HEALTH_INTERVAL_MS);
</script>
</body>
</html>
"""s02.5 后端进程共享的工作目录与文件路径边界。"""
from pathlib import Path
# 本文件位于章节根部;不依赖进程启动位置。
WORKDIR = Path(__file__).resolve().parent
def safe_path(path: str) -> Path:
"""拒绝通过 ../、绝对路径或符号链接访问工作目录外的文件。"""
resolved = (WORKDIR / path).resolve()
if not resolved.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {path}")
return resolved
Conversation 保存两种历史
Conversation 内部同时保存 messages 和 turns:
@dataclass(frozen=True) class ConversationTurn: run_id: UUID user_input: str assistant_output: str @dataclass(frozen=True) class Conversation: conversation_id: UUID messages: tuple[ChatCompletionMessageParam, ...] = () turns: tuple[ConversationTurn, ...] = ()
messages 是模型协议历史,包含 user、assistant、assistant tool calls 和
role="tool" 结果。下一次调用模型时必须保留完整的调用标识配对,不能只保存
页面上看到的问答文本。
turns 是面向客户端的公开历史,只包含每次 Run 的输入、最终回答和 run_id。
浏览器恢复页面时不需要理解 OpenAI tool call 协议,也不会看到内部工具结果。
这两份数据不是两个独立事实来源。它们在一次成功提交中由同一个 Run 同时产生:
messages 服务于模型续写,turns 服务于外部展示。只更新其中一份会破坏一致性,
因此写入动作集中在 ConversationService。
模型历史使用元组保存,Conversation 使用 frozen=True,用于限制普通调用方
直接追加或重新绑定字段。这仍然不是深度不可变:消息本身是字典。
Store 在读写时执行 deepcopy(),避免调用方修改嵌套消息后绕过 save()。
AgentRunner 隔离模型循环
应用服务不应该知道 OpenAI 请求格式、工具 Schema 或 tool_call_id。
它只依赖一个窄接口:
@dataclass(frozen=True) class AgentResult: answer: str messages: tuple[ChatCompletionMessageParam, ...] class AgentRunner(Protocol): async def run( self, history: Sequence[ChatCompletionMessageParam], ) -> AgentResult: ...
ToolCallingAgent 实现这个接口。它为本次执行创建消息副本,在开头加入系统提示,
然后继续使用 s02 已建立的 OpenAI-compatible 工具循环:
- 将消息和
runtime.schemas()发送给模型。 - 保存完整 assistant 消息,包括所有 tool calls 和调用标识。
- 按顺序执行每个工具。
- 将结果追加为对应
tool_call_id的 tool 消息。 - 模型不再调用工具时返回最终文本和完整新历史。
系统提示属于当前后端部署配置,不写入 Conversation。这样每次模型请求都会获得 系统提示,但公开历史和持久化数据不会把运行配置伪装成用户消息。
AgentRunner 还让应用服务测试不必启动真实模型。测试替身可以记录收到的历史、
控制并发时机或主动抛出异常,从而验证会话语义而不是 HTTP SDK 的实现细节。
一次 Run 的提交过程
ConversationService.run() 是后端状态变更的中心:
async def run(self, conversation_id: UUID, user_input: str) -> CompletedRun: await self.get(conversation_id) lock = self._locks.setdefault(conversation_id, asyncio.Lock()) async with lock: conversation = await self.get(conversation_id) run_id = self._run_id_factory() user_message: ChatCompletionMessageParam = { "role": "user", "content": user_input, } history = (*conversation.messages, user_message) result = await self._runner.run(history) turn = ConversationTurn( run_id=run_id, user_input=user_input, assistant_output=result.answer, ) updated = Conversation( conversation_id=conversation_id, messages=result.messages, turns=(*conversation.turns, turn), ) await self._store.save(updated) return CompletedRun( conversation_id=conversation_id, run_id=run_id, message=result.answer, )
执行顺序不能随意交换:
flowchart LR
accTitle: Run 的串行执行与提交
accDescr: 后端先确认 Conversation 存在并获取该会话的锁,再重新加载最新快照、追加本次用户消息并运行 Agent;只有 Agent 正常完成才保存新历史,失败则保留原快照。
request["接收 input"]
exists{"Conversation 存在"}
lock["获取该会话的锁"]
load["重新加载最新快照"]
append["在本地副本追加 user"]
execute["AgentRunner.run"]
outcome{"正常完成"}
save["同时保存 messages 与 turn"]
completed(["completed"])
unchanged(["原快照不变"])
request --> exists
exists -->|"否"| missing(["404"])
exists -->|"是"| lock
lock --> load
load --> append
append --> execute
execute --> outcome
outcome -->|"是"| save
save --> completed
outcome -->|"否"| unchanged
class lock,load,append,save added
class outcome,unchanged attention
第一次存在性检查发生在锁外,用于避免任意无效 UUID 持续占用锁表。 进入锁后必须重新加载 Conversation,因为等待期间前一个 Run 可能已经提交了 新历史。后一个 Run 应基于这份最新快照执行。
为什么锁必须按 Conversation 划分
如果没有锁,两个请求可能同时读取历史 H0:
Run A: H0 + input_A -> H1 Run B: H0 + input_B -> H2
最后一次 save() 会覆盖另一次结果。数据库写入本身即使是线程安全的,
也不能保证“读取旧历史、执行模型、写入新历史”这一整段业务操作不会竞争。
如果使用一个全局锁,虽然不会覆盖,但所有 Conversation 都必须排队。 一次耗时工具调用会阻塞其他用户,吞吐量退化为整个进程一次只能执行一个 Run。
实现使用 dict[UUID, asyncio.Lock]:
- 同一 Conversation 的 Run 串行,后一个请求读取前一个请求的结果。
- 不同 Conversation 使用不同锁,可以并行调用模型和工具。
这是单进程协调,不是分布式锁。多 worker 或多实例部署时,每个进程都有独立锁表, 必须由持久化层的版本检查、事务或跨进程协调机制替代。
失败不提交的准确含义
Agent 在本地消息副本上执行。只有 _runner.run() 正常返回后,
服务才创建公开 Turn 并调用 store.save()。模型请求失败、工具 handler 出现
编程错误或循环被取消时,原 Conversation 快照保持不变。
这项保证只覆盖会话状态,不是完整事务。工具可能已经执行真实副作用:
- 文件已经写入。
- Shell 命令已经运行。
- 外部 API 已经接收请求。
后续模型调用失败时,删除未提交的消息历史无法撤销这些操作。 要实现副作用回滚,需要工具自身提供幂等键、补偿操作或事务能力, 不能由 Conversation Store 自动完成。
ConversationStore 是可替换边界
应用服务依赖 ConversationStore 协议:
class ConversationStore(Protocol): async def create(self) -> Conversation: ... async def get( self, conversation_id: UUID, ) -> Conversation | None: ... async def save(self, conversation: Conversation) -> None: ...
本章使用 InMemoryConversationStore,因为要先明确服务端状态所有权,
不把数据库选型混入核心机制。它通过一个短时间持有的内部锁保护字典读写,
并在边界复制快照。
内存实现有明确限制:
- 进程重启后 Conversation 全部丢失。
- 多 worker 各自持有不同字典,请求落到另一 worker 时会得到 404。
- 会话锁只在当前进程有效。
- 没有过期回收、容量限制和所有权校验。
因此当前服务只能使用单 worker。生产持久化不是把字典替换成数据库调用就结束: Store 还需要版本号或事务,防止多个进程从同一旧版本生成并覆盖新快照。
HTTP 协议只暴露领域状态
创建 Conversation:
POST /api/conversations
{ "conversation_id": "6dd71267-1c81-4d31-a16b-cfdbcbd86d1b" }
执行一次 Run:
POST /api/conversations/6dd71267-1c81-4d31-a16b-cfdbcbd86d1b/runs Content-Type: application/json {"input": "读取 README.md 并总结"}
{ "status": "completed", "conversation_id": "6dd71267-1c81-4d31-a16b-cfdbcbd86d1b", "run_id": "02bf49da-f12a-402f-aee2-0aa75f47d9bf", "message": { "role": "assistant", "content": "..." } }
读取公开历史:
GET /api/conversations/6dd71267-1c81-4d31-a16b-cfdbcbd86d1b
客户端不提交 messages。请求模型使用的完整历史由后端根据
conversation_id 读取,避免客户端成为权威状态来源。
CompletedRunResponse 使用字面量状态:
class CompletedRunResponse(ApiModel): status: Literal["completed"] = "completed" conversation_id: UUID run_id: UUID message: MessageResponse
status 是 Run 响应的判别字段。当前只有 completed,但客户端不需要根据
HTTP 响应形状猜测执行结果。s03 可以增加 approval_required 变体,
同时保留已完成响应的字段语义。
未知 Conversation 返回结构化错误:
{ "error": { "code": "conversation_not_found", "message": "Conversation not found: ..." } }
错误码供接入器判断分支,message 用于日志或界面展示。
它比把异常文本塞进成功响应更容易扩展,也不会把“Agent 的工具结果”
与“HTTP 请求本身失败”混为一谈。
FastAPI 管理进程级资源
模型客户端和工具运行时属于后端进程,而不是单个 HTTP 请求。 FastAPI lifespan 负责组装和释放它们:
@asynccontextmanager async def lifespan(application: FastAPI) -> AsyncGenerator[None]: settings = load_settings() runtime = build_runtime() store = InMemoryConversationStore() async with create_client(settings) as client: runner = ToolCallingAgent( runtime, client=client, model=settings.model, system_prompt=system_prompt, ) application.state.conversation_service = ConversationService( store, runner, ) yield
这样安排有三个结果:
- 多次请求复用
AsyncOpenAI的连接池,不为每个 Run 重建客户端。 - 服务关闭时明确释放 HTTP 连接。
- 导入
api.app不会立刻创建模型客户端,测试可以替换依赖。
bootstrap.py 继续负责选择本进程启用的工具,FastAPI 不直接导入具体 handler。
资源生命周期、应用组装和工具实现分别位于不同边界。
Web 只是接入器
单文件 Web 页面只在 localStorage 中保存一个 conversation_id:
- 首次打开页面时调用
POST /api/conversations。 - 发送消息时只提交当前 ID 和
input。 - 刷新页面时调用
GET /api/conversations/{id}恢复公开 Turn。 - 新建对话时申请新 ID,不清空或伪造服务端历史。
- 服务重启导致旧 ID 不存在时,客户端创建新的 Conversation。
页面不理解 OpenAI 消息类型、工具调用或内部错误结果。 未来的 CLI、飞书接入器或其他前端只要遵守相同 HTTP 契约,就能共享后端语义。