跳至内容

实验 03 — 工具

目标: 使用 @define_tool、Pydantic 参数元数据和 client.create_session(..., tools=[...]) 替换静态提示上下文,让模型可以在需要时调用一个真实的 Python 函数。

时间: ~20分钟

先决条件: 实验 02 已完成。

步骤 1 — 为什么使用工具

In Lab 02,你通过发送零售事实到系统消息中来基础化助手。这种方法适用于小型示例,但它存在三个问题:

  1. 上下文是静态的——它只知道你之前粘贴的内容
  2. 模型必须猜测哪些事实是重要的
  3. 每个事实都会消耗令牌,即使答案不需要它

一个工具改变了问题的结构。不再指望提示中包含正确的数据,而是注册了一个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 作为这些实验的首选模型,但会退回到您账户中可用的特定模型。您可以覆盖它为:

uv run python -m sdk_labs tools --model gpt-5

第 4 步 —— 不要跳过权限处理程序

⚠️ 这是真的与 .NET 示例不同。

In Python,除非你传递 on_permission_request,否则自定义工具调用将被拒绝。如果不传递,模型将收到一个权限失败而不是工具结果,并且会以错误的形式回复。

对于一个示例实验,在每条请求都被允许的情况下,使用:

on_permission_request=PermissionHandler.approve_all

对于生产代码,提供一个策略函数。步骤8涵盖了安全注意事项。

步骤 5 — 运行

src/AgentOrchestrator-python:

uv run python -m sdk_labs tools

预期结果:

== 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有四个活动部件:

  1. 提示词要求查询客户 C003 的总花费
  2. The 模型 决定 注册 的 工具 是 回答 的 正确 方式
  3. The SDK 调用 Python 函数,产生 [tool]
  4. The 工具 结果 被 反馈 给 模型, 模型 写 出 最终 的 答案

[tool] get_customer_total(C003) -> $1,700.00 行不是模拟输出,而是当 SDK 处理模型的工具调用时,由真实的 get_customer_total 函数打印。

该工具返回一个普通字符串,而不是自定义的 SDK 结果对象:

return f"{params.customer_id} has {len(matches)} transactions totalling ${total:,.2f}."

步骤 7 — 理解事件交付

The 示例仍然使用你在实验 02 中看到的事件模型。Python 事件是只推送的回调:session.on(handler) 注册了处理程序并返回一个解绑调用函数。没有异步迭代器。

SessionEvent 是一个数据类,包含如 dataidtimestamptype 等字段。type 是一个 SessionEventType 枚举,因此 Python 会根据 evt.type 分支处理。这与 .NET 的一个子类对应一个事件模式完全不同。

The 共享 IdleWaitersdk_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 中的提示词改为:

await session.send(
    "How much has customer C999 spent in total? Use the available tool."
)

重新运行示例,并检查助手报告未找到任何交易。

然后尝试一个不需要零售数据的提示:

await session.send("In one short sentence, define average order value.")

模型应该直接作答。因为不需要进行客户查找,所以不应出现[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] 为什么在你实际运行的主机上必须验证权限钩子

💡 拓展练习

添加一个第二个工具,返回客户已购买的产品类别,例如 ElectronicsFashion,对于 C003

使用第二个 Pydantic 参数模型,或者重用 GetCustomerTotalParams,给工具提供一个精确的描述,将其注册在 get_customer_total 旁边,然后询问:

Which categories has customer C003 bought from, and how much have they spent?

检查模型是否调用一个工具、两个工具,还是直接回答。