实验 03 — 工具¶
目标: 使用 @define_tool、Pydantic 参数元数据和 client.create_session(..., tools=[...]) 替换静态提示上下文,让模型可以在需要时调用一个真实的 Python 函数。
时间: ~20分钟
先决条件: 实验 02 已完成。
步骤 1 — 为什么使用工具¶
In Lab 02,你通过发送零售事实到系统消息中来基础化助手。这种方法适用于小型示例,但它存在三个问题:
- 上下文是静态的——它只知道你之前粘贴的内容
- 模型必须猜测哪些事实是重要的
- 每个事实都会消耗令牌,即使答案不需要它
一个工具改变了问题的结构。不再指望提示中包含正确的数据,而是注册了一个Python函数。模型决定何时需要该函数,然后向SDK请求调用它,接收结果,最后写出最终答案。
步骤 2 — 检视工具的结构¶
打开 sdk_labs/tools_sample.py,并找到 get_customer_total。
一个Copilot SDK工具开始时是一个普通的Python函数,使用Pydantic参数模型:
class GetCustomerTotalParams(BaseModel):
"""Parameter schema sent to the model.
Where C# reads ``[Description]`` attributes off the method signature, Python
describes parameters with a Pydantic model — the field descriptions are what
the model sees.
"""
customer_id: Annotated[str, Field(description="Customer identifier, for example C003")]
@define_tool(description="Gets the total amount a given retail customer has spent.")
def get_customer_total(params: GetCustomerTotalParams, _invocation: ToolInvocation) -> str:
matches = [t for t in TRANSACTIONS if t[0].casefold() == params.customer_id.casefold()]
if not matches:
return f"No transactions found for {params.customer_id}."
total = sum(t[1] for t in matches)
print(f" [tool] get_customer_total({params.customer_id}) -> ${total:,.2f}")
return f"{params.customer_id} has {len(matches)} transactions totalling ${total:,.2f}."
所需的签名是
(params: SomePydanticModel, _invocation: ToolInvocation) -> str.
The关键教学差异来自C#的是元数据。.NET使用[Description]属性在方法和参数上。Python使用:
@define_tool(description=...)用于工具描述Field(description=...)在 Pydantic 模型字段的参数描述中
这些描述是模型的 API 文档。模糊的描述会导致遗漏工具或使用错误的参数,因此要像一个公共 API 一样撰写。
步骤 3 — 注册工具¶
The sample 创建了一个客户端,选择了一个模型,然后在创建会话时注册了工具:
async with CopilotClient() as client:
model_id = await model_picker.pick(client, requested_model_id)
if model_id is None:
return 1
session = await client.create_session(
model=model_id,
streaming=False,
tools=[get_customer_total],
# Required in Python, unlike .NET: the runtime asks permission before
# invoking a custom tool, and with no handler the call is denied and
# the model reports a permission error instead of an answer.
on_permission_request=PermissionHandler.approve_all,
)
那tools=[get_customer_total]行是“模型有一些文本上下文”和“模型可以要求宿主应用程序执行实际工作”的区别。
model_picker 保持将 claude-haiku-4.5 作为这些实验的首选模型,但会退回到您账户中可用的特定模型。您可以覆盖它为:
第 4 步 —— 不要跳过权限处理程序¶
⚠️ 这是真的与 .NET 示例不同。
In Python,除非你传递 on_permission_request,否则自定义工具调用将被拒绝。如果不传递,模型将收到一个权限失败而不是工具结果,并且会以错误的形式回复。
对于一个示例实验,在每条请求都被允许的情况下,使用:
对于生产代码,提供一个策略函数。步骤8涵盖了安全注意事项。
步骤 5 — 运行¶
从 src/AgentOrchestrator-python:
预期结果:
== Lab 03: tools ==
Model: claude-haiku-4.5
Prompt: How much has customer C003 spent in total?
[tool] get_customer_total(C003) -> $1,700.00
Assistant: Customer C003 has spent a total of **$1,700.00** across 2 transactions.
⚠️ 注意缺失的内容:与 .NET 转录不同,Python 示例中没有空的第一行 Assistant:。第一助理事件仅携带工具请求,因此 tools_sample.py 明智地跳过了内容为空的消息。
第 6 步 — 跟踪发生了什么¶
The run有四个活动部件:
- 提示词要求查询客户
C003的总花费 - The 模型 决定 注册 的 工具 是 回答 的 正确 方式
- The SDK 调用 Python 函数,产生
[tool]行 - The 工具 结果 被 反馈 给 模型, 模型 写 出 最终 的 答案
该 [tool] get_customer_total(C003) -> $1,700.00 行不是模拟输出,而是当 SDK 处理模型的工具调用时,由真实的 get_customer_total 函数打印。
该工具返回一个普通字符串,而不是自定义的 SDK 结果对象:
步骤 7 — 理解事件交付¶
The 示例仍然使用你在实验 02 中看到的事件模型。Python 事件是只推送的回调:session.on(handler) 注册了处理程序并返回一个解绑调用函数。没有异步迭代器。
SessionEvent 是一个数据类,包含如 data、id、timestamp 和 type 等字段。type 是一个 SessionEventType 枚举,因此 Python 会根据 evt.type 分支处理。这与 .NET 的一个子类对应一个事件模式完全不同。
The 共享 IdleWaiter 在sdk_labs/_common.py 保持示例从永远等待,如果会话从未达到空闲状态。
第 8 步 — 控制工具执行权限¶
The SDK通过<code>on_permission_request</code>暴露一个内嵌的权限钩子 on_permission_request一个自定义策略可以检查请求并返回<code>copilot.rpc</code>中的一个决策对象 copilot.rpc例如:
from copilot.rpc import PermissionDecisionApproveOnce, PermissionDecisionReject
# Not re-exported at the package root in SDK 1.0.9 — import it from the module.
from copilot.session import PermissionInvocation
The 参考诊断实现:
def on_permission_request(
request: PermissionRequest, invocation: PermissionInvocation
) -> PermissionRequestResult:
# `kind` is a class attribute on each request type ("shell", "custom-tool",
# "write", …), where the C# version reads a `Kind` property off one type.
kind = getattr(request, "kind", "")
tool_name = getattr(request, "tool_name", "") or ""
print(f" [permission] requested: kind={kind} tool={tool_name or '(n/a)'}")
# Policy: allow reads, refuse anything destructive.
if "delete" in f"{kind} {tool_name}".casefold():
print(" [permission] -> REJECTED by policy")
return PermissionDecisionReject(
feedback="Destructive operations are not permitted in this demo."
)
print(" [permission] -> approved once")
return PermissionDecisionApproveOnce()
Python 的优势:无需抑制,
.NET 项目必须抑制实验性 API 构建错误以使用权限决策。
Python 直接暴露它们,无需选择。 无 GHCP001 步骤。 copilot.rpc copilot.rpc 无需选择。
⚠️ 权限处理器的注意:该处理器为自定义工具触发,并且Lab 03工具需要它。它没有被观察为shell命令触发。运行权限诊断,模型执行了echo,并且报告了输出,没有打印[permission]行,因为主机Copilot CLI已经授予了shell的批准。
将<code>on_permission_request</code>视为自定义工具策略钩子,而不是一般性的执行点,在依赖它之前验证它在您的环境中触发。 on_permission_request 作为自定义工具策略钩子,而不是一般性的执行点。在您的环境中验证它是否触发 您的环境 before relying on it.
参考代码位于
sdk_labs/permissions_sample.py。
对于一个 shell-hook 治理的替代方案,请参见
extra-governance-hooks。
步骤 9 — 实验¶
尝试一个不存在的客户,例如 C999。将 tools_sample.py 中的提示词改为:
重新运行示例,并检查助手报告未找到任何交易。
然后尝试一个不需要零售数据的提示:
模型应该直接作答。因为不需要进行客户查找,所以不应出现[tool]行。
✅ 检查点¶
你现在可以解释:
- [x] 工具比动态数据塞入系统消息更好
- [x] 如何描述一个 Python 工具,通过使用代码块
@define_tool(description=...) - [x] 如何 Pydantic
Field(description=...)描述工具参数 - [x] 如何
tools=[get_customer_total]使功能可用给模型 - [x] Python 自定义工具需要
on_permission_request - [x] 为什么 Python 事件分支基于
evt.type - [x] 为什么在你实际运行的主机上必须验证权限钩子
💡 拓展练习¶
添加一个第二个工具,返回客户已购买的产品类别,例如 Electronics 和 Fashion,对于 C003。
使用第二个 Pydantic 参数模型,或者重用 GetCustomerTotalParams,给工具提供一个精确的描述,将其注册在 get_customer_total 旁边,然后询问:
检查模型是否调用一个工具、两个工具,还是直接回答。