实战 02:简易 OpenCode——Python 编码 Agent

2026-09-01
14695 分钟
...

1、本篇交付物

使用 Python、FastAPI、SQLite、Pydantic 和 LangGraph 完成一个最小 Server:创建 Session、发送 Prompt、SSE 订阅事件、执行只读工具、产生编辑权限请求、批准后恢复并应用 Patch。真实模型最后接入,开发阶段先用 FakeModel 保证测试确定。

2、项目结构

opencode_py/
  app.py                 # FastAPI 路由和生命周期
  contracts.py           # Session/Message/Part/Event schema
  repository.py          # SQLite 事务
  event_bus.py            # 持久事件 + 本地订阅
  providers/base.py       # 模型统一接口
  providers/fake.py
  context_builder.py      # 规则消息压缩和 token 预算
  tools/base.py
  tools/files.py
  permissions.py
  workspace.py
  graph.py
tests/

3、核心合同

from datetime import datetime
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class Session(BaseModel):
    id: str
    project_id: str
    mode: Literal["plan", "build"] = "plan"
    status: Literal["idle", "running", "waiting_permission", "failed"] = "idle"
    last_event_seq: int = 0

class TextPart(BaseModel):
    type: Literal["text"] = "text"
    id: str
    text: str = ""

class ToolPart(BaseModel):
    type: Literal["tool"] = "tool"
    id: str
    call_id: str
    tool: str
    status: Literal["pending", "waiting_permission", "running", "completed", "error"]
    input: dict
    output_artifact_id: str | None = None

Part = Annotated[Union[TextPart, ToolPart], Field(discriminator="type")]

class Event(BaseModel):
    session_id: str
    seq: int
    type: str
    payload: dict
    created_at: datetime

API、数据库 JSON 和 SSE 都使用这些 schema。数据库读取出的 JSON 仍要 model_validate,不能因为是自己的数据就跳过版本/结构检查。

4、SQLite 事务和事件序号

每次状态变更与事件写入同一事务:

def append_event(conn, session_id: str, event_type: str, payload: dict) -> Event:
    row = conn.execute(
        "UPDATE sessions SET last_event_seq=last_event_seq+1 WHERE id=? RETURNING last_event_seq",
        (session_id,),
    ).fetchone()
    if row is None:
        raise LookupError("SESSION_NOT_FOUND")
    event = Event(session_id=session_id, seq=row[0], type=event_type,
                  payload=payload, created_at=datetime.utcnow())
    conn.execute(
        "INSERT INTO events(session_id,seq,type,payload,created_at) VALUES(?,?,?,?,?)",
        (session_id, event.seq, event.type, event.model_dump_json(), event.created_at.isoformat()),
    )
    return event

单 Session 序号严格递增;UNIQUE(session_id, seq) 防止并发重复。事务提交后再通知内存 subscriber;订阅者漏掉通知也能从 events 表按 seq 补读。

5、SSE:先补历史,再等待新事件

from fastapi.responses import StreamingResponse

def encode_sse(event: Event) -> str:
    return f"id: {event.seq}\nevent: {event.type}\ndata: {event.model_dump_json()}\n\n"

@app.get("/sessions/{session_id}/events")
async def events(session_id: str, after: int = 0, actor=Depends(auth)):
    require_session_owner(actor, session_id)

    async def stream():
        cursor = after
        while True:
            batch = repo.events_after(session_id, cursor, limit=100)
            for event in batch:
                cursor = event.seq
                yield encode_sse(event)
            if batch:
                continue
            await event_bus.wait(session_id, cursor, timeout=15)
            yield ": keepalive\n\n"

    return StreamingResponse(stream(), media_type="text/event-stream")

生产要处理客户端取消、subscriber 清理、最大事件保留和窗口过期。SSE 不携带密钥与内部 stack。

6、模型 Provider 与 FakeModel

class ModelChunk(BaseModel):
    type: Literal["text_delta", "tool_call", "finish"]
    text: str | None = None
    call_id: str | None = None
    tool: str | None = None
    arguments: dict | None = None
    reason: str | None = None

class ModelProvider(Protocol):
    async def stream(self, messages: list[dict], tools: list[dict], *, signal) -> AsyncIterator[ModelChunk]: ...

FakeModel 根据测试脚本依次返回 text/tool call/finish,可稳定覆盖审批、重连和错误。真实 Provider adapter 负责把 LangChain/provider chunk 转为内部 ModelChunk,并记录 usage、模型版本和原始 request ID。

7、Tool Registry

class ToolContext(BaseModel):
    session_id: str
    project_root: str
    workspace_root: str
    mode: Literal["plan", "build"]

class ToolSpec(BaseModel):
    name: str
    description: str
    input_schema: dict
    permission: Literal["read", "edit", "execute", "network"]

class Tool(Protocol):
    spec: ToolSpec
    async def execute(self, raw: dict, ctx: ToolContext) -> dict: ...

read_file 输入只有相对 path、offset、limit;root 从 ToolContext 获得。先做路径规范化、symlink/保留目录/大小检查,再读取。grep 使用 argv 调 rg 或受控纯 Python 实现,并限制 pattern、glob、结果数和超时。

apply_patch 不直接相信 patch 文本:解析每个 marker 路径、验证工作区、文件 hash、总修改量并 dry-run。写入前生成 snapshot 和 Patch artifact。

8、Permission Engine

class Rule(BaseModel):
    tool: str
    pattern: str = "*"
    action: Literal["allow", "ask", "deny"]
    scope: Literal["global", "project", "session"]

def decide(rules: list[Rule], tool: ToolSpec, input_summary: str, mode: str) -> str:
    if mode == "plan" and tool.permission in {"edit", "execute"}:
        return "deny"
    matches = [r for r in rules if fnmatch(tool.name, r.tool) and fnmatch(input_summary, r.pattern)]
    matches.sort(key=specificity, reverse=True)
    return matches[0].action if matches else "ask"

ask 时在事务中创建 permission request、更新 ToolPart 和 Session 状态,然后图 interrupt。响应 API 验证 owner、pending 状态、call/input hash 和过期时间;“记住”只创建同等或更窄规则。

9、LangGraph Agent Loop

class AgentState(TypedDict, total=False):
    session_id: str
    turn_id: str
    messages: list[dict]
    pending_call: dict
    permission_request_id: str
    budget: dict
    done: bool

def call_model(state, runtime): ...       # 流式写 Parts/Events返回 pending_call done
def check_permission(state, runtime): ... # allow/deny/interrupt
def run_tool(state, runtime): ...         #  ToolPart artifact
def route(state): return END if state["done"] else "check_permission" if state.get("pending_call") else "call_model"

builder = StateGraph(AgentState)
builder.add_node("call_model", call_model)
builder.add_node("check_permission", check_permission)
builder.add_node("run_tool", run_tool)
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", route)
builder.add_conditional_edges("check_permission", permission_route)
builder.add_edge("run_tool", "call_model")
graph = builder.compile(checkpointer=production_checkpointer)

数据库 Session/Message 是产品权威;Graph state 保存当前 turn 的执行游标。节点重试必须通过 call ID/tool execution 唯一约束避免重复写。

10、API

POST /projects/open                  验证并注册本地项目
POST /sessions                      创建 Session
GET  /sessions/:id                  snapshotSession + Messages + Parts + pending permission
POST /sessions/:id/messages         入库用户消息并启动后台 turn
GET  /sessions/:id/events?after=n   SSE
POST /permissions/:id/respond       allow_once / allow_rule / deny
POST /sessions/:id/cancel           取消当前 turn
POST /sessions/:id/undo             应用反向 Patch

发送消息接口返回 202,不等待模型;每个 Session 同时只允许一个活跃 turn,或明确实现排队。

11、测试顺序

先测试 repository 事务和 seq;再测试 safe path、grep、Patch;再用 FakeModel 跑完整图;最后启动 FastAPI 做 SSE/权限集成测试。关键场景:客户端断线不重跑;Plan 模式 edit 被拒绝;批准后文件已变化返回 stale;相同 call ID 不重复执行;取消能结束模型与命令;Server 重启后从 SQLite/checkpoint 恢复。

做到这一步,Python 版已经是一个可运行的本地编码 Agent Server。后续 TUI、上下文压缩、LSP/MCP 和 Sandbox 在专题篇继续完善。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
AI2026-09-01
Deep Agents 01:何为 Agent Harness,以及如何开始
1、本篇任务:完成一份多步骤、带证据的技术调研 普通客服 Agent 的问题短、工具少、输出即时。技术调研或编码任务会持续很久,产生计划、搜索结果、文件和中间结论。Deep Agents 在 LangChain/LangGraph 之上预装规划、虚拟文件系统、上下文压缩和子 Agent,适合这类开放任务。 本课让 Agent 比较两种向量数据库,并交付一份可验证报告。 2、什么时候需要 Deep Agent 满足以下两项以上再考虑:任务...
学习
AI2026-09-01
Deep Agents 02:子 Agent、虚拟文件系统与长期记忆
1、本篇任务:让主管只看结论,让子 Agent 处理细节 技术调研会产生几十次搜索和大量文件。如果全部进入主管上下文,真正的目标会被噪音淹没。本课用两个子 Agent: 收集证据, 检查结论;主管负责计划与最终合成。 2、什么时候委派,什么时候直接调用工具 适合委派:子任务有多步;需要专门提示或工具;会产生大量中间结果;只需返回有限结论。不适合:一步查询;主管需要全部中间上下文;协调成本超过任务本身。 3、配置专门子 Agent Pyt...
学习
AI2026-09-01
Deep Agents 03:生产化、Sandbox、权限与上线验收
1、本篇任务:让 Deep Agent 在隔离环境中分析代码 只读研究 Agent 风险有限;编码 Agent 需要读写文件、安装依赖和执行测试。本课不讲如何让模型写更漂亮的代码,只讲执行环境、权限、恢复和上线验收。 2、先做威胁模型 资产包括源代码、用户文件、云凭证、生产网络和发布权限;攻击入口包括用户消息、仓库内容、网页、依赖包、MCP 返回和命令输出。 Prompt injection 不是靠一句 system prompt 解决...
学习
AI2026-09-01
LangChain 01:全景、原理与学习路线
1、本篇学完要得到什么 这一篇只解决三个问题:LangChain 到底负责什么;它与 LangGraph、Deep Agents、LangSmith 是什么关系;后面应按什么顺序学习。 贯穿整套课程的项目是“退款政策与订单助手”。它最终能够:回答知识库中的退款规则;查询当前用户的订单;生成结构化答复;对真正的退款操作进行人工审批;断线后恢复;通过评测后发布。 先记住一句话: 模型负责理解与生成,应用负责数据、权限、状态和副作用。 如果把...
学习
AI2026-09-01
LangChain 02:模型、消息与结构化输出
1、本篇任务:让模型输出成为程序可以依赖的合同 上一课只证明 Agent 能运行。本课暂时不接业务工具,只完成一个“客服分诊器”:输入用户问题,输出意图、紧急程度、是否需要人工和给用户的答复。 本课的核心不是学更多模型参数,而是理解三层合同:消息决定模型看到了什么;schema 决定程序期待什么;业务校验决定结果是否真的可用。 2、消息不是一段字符串,而是一条执行记录 一次工具型对话通常包含四种消息: | 类型 | 由谁产生 | 作用...
学习
AI2026-09-01
LangChain 03:工具与 Agent——从函数到可控行动
1、本篇任务:让 Agent 安全地读取订单 上一课得到结构化分诊结果,但模型不知道真实订单。本课增加一个只读工具 ,走通完整 Agent 循环,并把模型、工具包装和领域服务的责任分开。 完成后,用户问“我的 A100 发货了吗”,Agent 会选择工具;工具只按当前登录用户查询;模型基于工具结果回答。它仍然不能退款,因为我们没有提供写工具。 2、工具的本质是受 schema 约束的应用函数 一个好工具需要:稳定名称、清楚描述、窄输入...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录