
前面折腾过 .NET 版本的仓库维护智能体,能检查代码、提出修复方案、运行测试、报告结果,所有写操作和 shell 命令都需要人工确认。这篇来用 Python 实现同样的功能。
安全策略和实现目标故意保持一致——不想因为 Python 实现起来更方便就悄悄选个更简单的演示。两个版本使用相同的测试用例、相同的指令、在相同的审批节点停下、必须通过相同的测试。唯一不同的是宿主语言。
最终得到的是一个异步 Python 命令行应用,用 GitHub Copilot 的执行框架处理仓库操作,用 Microsoft Agent Framework 处理智能体抽象、流式输出、会话管理和遥测。
💡 代码仓库: Python 和 .NET 两个实现共用同一套测试用例,代码在 github.com/sahansera/safe-repository-maintenance-agent。
智能体接收一个本地仓库路径和一个维护任务。它的系统指令要求它:
AGENTS.md自带的测试用例包含一个小 JavaScript 函数。智能体用 Python 写,但目标仓库不一定是 Python 的:
export function normalizeTitle(value) { return value.toLowerCase().replace(/\s+/g, "-");
}
第一个测试期望普通的标题标准化。第二个测试期望忽略标题周围的空白。在实现加上 trim 之前,第二个测试会失败。
故意选一个语言无关的目标。Python 智能体可以维护 .NET、JavaScript、Go 或者文档仓库。智能体的宿主语言只决定我们如何集成和运行这个框架,不决定它能理解哪些源码文件。
需要 Python 3.11 或更高版本,以及一个有效的 GitHub Copilot 订阅。示例用的是 Python 3.12 作为文档基准,也验证过 Python 3.13。
实际测试环境:
[project]
requires-python = ">=3.11"
dependencies = [ "agent-framework-github-copilot==1.0.1", "github-copilot-sdk==1.0.2",
] [project.optional-dependencies]
dev = ["pytest>=8.4,<9"]
创建隔离环境并安装:
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
完整运行的效果:
当前的 Python SDK 包包含了支持平台的 Copilot 运行时。认证依赖 GitHub Copilot,首次运行可能会要求登录。
Agent Framework 集成是稳定的;但底层的 GitHub Copilot SDK 还不是,编写这两篇文章期间它的预览版不止一次改了接口。建议锁定版本——这是廉价的保险,防止未来的 SDK 升级悄悄改变你的代码行为。
智能体可以请求多种能力类型,包括 read、write、shell、url 和 mcp。我们不会一视同仁:
| 能力类型 | 默认行为 |
|---|---|
| 读取指定仓库内的文件 | 直接批准 |
| 写入文件 | 询问确认 |
| 执行 shell 命令 | 询问确认 |
| 访问 URL 或调用 MCP | 拒绝 |
| 未知类型 | 拒绝 |
策略就是一个普通函数:
from enum import Enum class PolicyDecision(Enum): APPROVE = "approve" PROMPT = "prompt" DENY = "deny" def decide(permission_kind: str) -> PolicyDecision: if permission_kind == "read": return PolicyDecision.APPROVE if permission_kind in {"write", "shell"}: return PolicyDecision.PROMPT return PolicyDecision.DENY
兜底策略拒绝 URL 访问、MCP 调用、新出现的 SDK 权限类型以及任何畸形值。要开启这些能力必须主动修改应用代码。
测试套件把这些契约明确化了:
@pytest.mark.parametrize( ("kind", "expected"), [ ("read", PolicyDecision.APPROVE), ("write", PolicyDecision.PROMPT), ("shell", PolicyDecision.PROMPT), ("url", PolicyDecision.DENY), ("mcp", PolicyDecision.DENY), ("unknown", PolicyDecision.DENY), ],
)
def test_decide_returns_expected_decision(kind, expected): assert decide(kind) is expected
这些测试不需要模型、Copilot 订阅或仓库。它们测试的是应用权限,而不是概率行为。
不需要用 LLM 评估来证明 URL 访问被拒绝了。把确定性的授权策略从智能体中分离出来,像测试其他安全敏感函数一样测试它。
权限处理器接收一个带类型的请求和一个上下文字典。首先打印足够的信息让操作者理解这个动作:
async def handle_permission(request, context): decision = decide(request.kind) print(f"\n[permission: {request.kind}]") print(describe(request)) if decision is PolicyDecision.APPROVE: return PermissionHandler.approve_all(request, context) if decision is PolicyDecision.DENY: return PermissionDecisionReject( feedback="Blocked by the repository agent policy." ) answer = ( await asyncio.to_thread(input, "Approve once? [y/N] ") ).strip().lower() if answer == "y": return PermissionHandler.approve_all(request, context) return PermissionDecisionReject( feedback="The operator denied this action." )
input() 是阻塞调用,所以用 asyncio.to_thread 把它从事件循环中挪出去。这个细节在控制台示例里很容易忽略,但在应用同时输出流式内容或者处理多个会话时会变得重要。
对于写请求,describe 打印 file_name 和 diff。对于 shell 请求,打印 full_command_text。URL 和 MCP 请求在拒绝之前也会显示,留下便于审计的记录。
辅助方法名叫 approve_all 在这个上下文里容易产生误解。它为当前请求构造一个批准响应。应用本身仍然决定哪些请求能到达这行代码。
命令在创建智能体之前先解析仓库路径:
repository = args.repository.expanduser().resolve(strict=True) if not repository.is_dir(): raise NotADirectoryError(repository)
Copilot 会话选项包含工作目录和权限回调:
options = GitHubCopilotOptions( working_directory=str(repository), enable_config_discovery=False, on_permission_request=handle_permission,
) agent = GitHubCopilotAgent( instructions=INSTRUCTIONS, default_options=options,
)
仓库指令确实有用,所以关闭配置发现乍一看是反直觉的。写 .NET 版本的时候遇到了原因:测试夹具在开发时放在另一个 Git 仓库里,运行时愉快地往上走到了父仓库的 AGENTS.md,根本没注意到测试夹具自己的文件。嵌套仓库、monorepo、临时 worktree 都容易在不经意间模糊这个边界。
所以应用指令明确告诉智能体去读取自己工作目录内的 AGENTS.md。这样项目指导的来源在工具活动中一目了然,而不是让一个无关的父目录悄悄改变智能体认为的规则。
这不意味着信任仓库指令。仓库可能包含 prompt 注入,就像可能包含恶意构建脚本一样。当指令请求一个被禁止的操作时,宿主策略仍然拥有最终决定权。
仓库指令可以改进补丁,但不能扩大智能体的权限。当项目指导请求一个被禁止的副作用时,主机权限策略说了算。
GitHubCopilotAgent 拥有一个异步客户端,所以自然的 Python 生命周期是异步上下文管理器:
async with agent: async for update in agent.run(args.task, stream=True): print(update.text, end="", flush=True)
上下文管理器在运行抛出异常时也会启动和停止 Copilot 客户端。命令入口点把同步边界保持得很小:
def run() -> None: raise SystemExit(asyncio.run(main()))
这个生命周期在长时间运行的应用中很重要。智能体会话拥有进程、连接、历史记录,有时候还有临时文件。异常不应该让这些资源无限期地挂在 worker 上。
用自带的测试夹具启动智能体:
safe-repo-agent ../fixture
它读取源码和测试,然后提出和 .NET 版本一样的一行修改:
- return value.toLowerCase().replace(/\s+/g, "-");
+ return value.trim().toLowerCase().replace(/\s+/g, "-");
写操作要等操作者批准才会执行。后面的 npm test 请求有自己的确认提示,所以批准一个补丁不等于授予持续执行任意命令的权限。
验证运行的结果:
File changed: src/normalize-title.js
Fix: Added .trim() before .toLowerCase().
Validation: Both tests pass (npm test exit 0).
单独运行确定性策略测试:
pytest
所有六个策略用例在没启动 Copilot 的情况下全部通过。
Python 的 GitHubCopilotAgent 内置了 Agent Framework 的遥测层。用于本地探索时,可以在创建智能体之前配置控制台导出器:
from agent_framework.observability import configure_otel_providers configure_otel_providers(enable_console_exporters=True)
在服务中,通过 OTLP 导出到常规的可观测性后端,并把仓库任务标识符附加到外层 trace。有用的信号包括:
不要轻易开启 prompt 和 completion 的捕获。仓库路径、源代码、终端输出和环境相关错误可能包含敏感信息。遥测应该解释运行过程,而不是变成智能体能看到的所有机密的又一个副本。
这个控制台应用演示的是控制点,不是一个完整的隔离平台。如果要让它在服务中处理不可信仓库,还需要加:
asyncio 感知的超时和取消,覆盖 CPU、内存、磁盘和墙上时钟限制,不是简单在配置文件里写个数字finally 块中的清理和审计记录,包括被取消的任务对确定性工具运行的仓库脚本同样适用这些考量。智能体让风险更容易看到,因为它动态选择命令,但仓库及其依赖本来就是不可信的可执行输入。
架构边界没变。两个实现使用相同的执行框架、工作目录、权限表、任务和测试夹具。
Python 版本用 async with 表达生命周期,通过 GitHubCopilotOptions 传递 Copilot 会话设置,把阻塞的操作者输入丢到线程里。.NET 版本用 CopilotClient、SessionConfig、带类型的权限请求子类,以及 IAsyncEnumerable 做流式处理。
这些是生态系统的差异,不是不同的安全模型。
同一个智能体写两遍,有一件事变得很清楚:包裹模型的语言几乎无关紧要。重要的是它工具周围的权限边界,以及"它成功了"是否有 diff 和通过的测试支撑,还是只是模型自己说自己成功。
感谢阅读 ✌️