错误码矩阵 · 实机兼容映射 · 契约变更规则

主题① 错误码矩阵

1.1 一句话概述

把"出错"这件事从一段字符串 升级成结构化的四字段 ,再集中维护一张"业务码 ↔ HTTP 码 ↔ 是否可重试"的映射表。目的是:LLM / 前端 / 运维看同一个错误,得到同一个结论,做同一个动作

1.2 四字段结构(与你现有 errors.py 对齐)

字段 含义 谁消费 示例
code 稳定业务码,机器可判 状态机、测试、LLM 逻辑分支 OUT_OF_WORKSPACE
message 人读的一句话 前端展示、日志 "位姿越界: radius=0.35m > 0.28m"
retryable 重试是否有意义 Agent 循环(决定重试 or 交状态机) true / false
details 附加上下文(字典) 调试、审计 {"target": "红色杯子", "confidence": 0.93}

1.3 为什么业务码和 HTTP 码要"分离"

  • HTTP 码只有几十个,表达力不够:同一个 409 下面可能是 ARM_BUSY(可重试)也可能是 ESTOP_ACTIVE(不可重试)。
  • 业务码是稳定的语义标识 ,HTTP 码是传输层速览 。客户端判断逻辑只认 code,HTTP 码只是给人快速定位的。
  • 结论:映射表单独维护code/message/retryable/http 一行一个),不要散落在各函数里。

1.4 retryable 判定三问(核心原则)

只有同时满足"瞬态 + 重试能成功 + 没超次数",才是 retryable=true

  1. 是瞬态问题吗? 机器忙、网络抖动、目标暂时没检测到 → 是。急停按下、权限不足、输入越界 → 不是。

  2. 同样的输入重试一次能成功吗? 能 → 可重试;不能(输入本身就是错的)→ 不可重试。

  3. 次数用完了吗? 按你的 Agent 约定:连续失败 2 次 → 交状态机,重试上限 2。

    retryable 判定示例(中文注释)

    def should_retry(err_code: str, attempt: int, max_attempts: int = 2) -> bool:
    """只有 瞬态 + 重试可成功 + 未超次数 三个条件同时满足才重试"""
    retryable_codes = {
    "ARM_BUSY", # 手臂忙:等一拍再试,很可能成功 → 可重试
    "TRAJECTORY_TIMEOUT", # 运动超时:重试 1 次有概率成功 → 可重试
    "OBJECT_NOT_FOUND", # 重新 detect 一次,目标可能再次出现 → 可重试
    }
    if err_code not in retryable_codes:
    return False # 不可重试:输入问题/权限/急停/内部错误
    if attempt >= max_attempts:
    return False # 超过次数:交给状态机兜底(你的 max_iter 约定)
    return True

1.5 代码:扩展版 ErrorCode + HTTP 映射

在你现有 errors.py 基础上建议扩展的错误码(只加不改,保持兼容):

复制代码
# 错误码枚举(扩展版,中文注释)------ 与现有 errors.py 的 ErrorCode/ErrorResponse 风格一致
from enum import StrEnum
from dataclasses import dataclass, field

class ErrorCode(StrEnum):
    # ------ 请求/契约类:HTTP 4xx,retryable 几乎都是 False ------
    INVALID_JSON = "INVALID_JSON"            # JSON 解析失败
    MISSING_FIELD = "MISSING_FIELD"          # 缺必填字段
    INVALID_TASK = "INVALID_TASK"            # 任务语义非法(非冻结目标名等)
    OUT_OF_WORKSPACE = "OUT_OF_WORKSPACE"    # 位姿越界:radius>0.28m 或 z 越界
    UNKNOWN_TARGET = "UNKNOWN_TARGET"        # 目标名不在白名单(负样本测试专用)
    FORBIDDEN = "FORBIDDEN"                  # 权限不足(operator 解除急停)
    FORBIDDEN_LOW_LEVEL_FIELD = "FORBIDDEN_LOW_LEVEL_FIELD"  # 请求里带了底层字段
    # ------ 机器状态类:HTTP 409,retryable 视情况 ------
    ARM_BUSY = "ARM_BUSY"                    # 手臂正在执行其他任务 → 瞬态,可重试
    ESTOP_ACTIVE = "ESTOP_ACTIVE"            # 急停激活 → 必须人工解除,不可重试
    DISABLED = "DISABLED"                    # 未使能
    GRIPPER_FAULT = "GRIPPER_FAULT"          # 夹爪故障
    # ------ 执行类 ------
    TRAJECTORY_TIMEOUT = "TRAJECTORY_TIMEOUT"  # 运动/规划超时 → 可重试 1 次
    COLLISION_RISK = "COLLISION_RISK"        # 碰撞风险
    # ------ 系统类 ------
    INTERNAL_ERROR = "INTERNAL_ERROR"        # 未知内部错误
    LLM_UNAVAILABLE = "LLM_UNAVAILABLE"      # 本地 LLM 服务不可达

@dataclass
class ErrorResponse:
    """四字段错误响应(与现有 errors.py 保持一致)"""
    code: str
    message: str
    retryable: bool
    details: dict = field(default_factory=dict)

    def to_http(self) -> tuple[int, dict]:
        """业务码 → HTTP 码:映射集中一处,避免散落各函数"""
        http_map = {
            ErrorCode.INVALID_JSON: 400,
            ErrorCode.MISSING_FIELD: 400,
            ErrorCode.INVALID_TASK: 400,
            ErrorCode.OUT_OF_WORKSPACE: 422,   # 语义正确但不可执行
            ErrorCode.UNKNOWN_TARGET: 404,     # 资源不存在
            ErrorCode.FORBIDDEN: 403,          # 权限不足
            ErrorCode.FORBIDDEN_LOW_LEVEL_FIELD: 403,
            ErrorCode.ARM_BUSY: 409,
            ErrorCode.ESTOP_ACTIVE: 409,
            ErrorCode.DISABLED: 409,
            ErrorCode.GRIPPER_FAULT: 409,
            ErrorCode.TRAJECTORY_TIMEOUT: 504,
            ErrorCode.COLLISION_RISK: 409,
            ErrorCode.INTERNAL_ERROR: 500,
            ErrorCode.LLM_UNAVAILABLE: 503,
        }
        status = http_map.get(self.code, 500)
        return status, {
            "code": self.code,
            "message": self.message,
            "retryable": self.retryable,
            "details": self.details,
        }

1.6 错误码矩阵表(≥8 行,这就是 docs/error-code-matrix.md 的雏形)

业务码 含义 HTTP retryable 典型场景 谁来修
INVALID_JSON JSON 无法解析 400 LLM/客户端输出坏 JSON 模型/客户端重发
MISSING_FIELD 缺必填字段 400 请求缺 target 调用方补字段
INVALID_TASK 任务语义非法 400 说"抓月亮" 换说法
OUT_OF_WORKSPACE 位姿越界 422 radius=0.35m / z=0.6m 改位姿(系统生成,不靠 LLM 手写)
UNKNOWN_TARGET 目标不在白名单 404 LLM 输出"蓝色杯子"(表里没有) 询问用户确认
FORBIDDEN 权限不足 403 operator 尝试解除急停 换 admin
ARM_BUSY 手臂忙 409 正在执行任务时又来新指令 等待后重试
ESTOP_ACTIVE 急停未解除 409 急停按下 人工解除
TRAJECTORY_TIMEOUT 运动超时 504 规划/执行超时 重试 1 次,再失败交状态机
INTERNAL_ERROR 未知内部错误 500 未捕获异常 运维介入

记忆口诀:"4xx 是请求的错,5xx 是机器的错;能不能重试,看是不是瞬态、重试能不能赢、次数够不够。"

主题② 实机兼容映射表(下午 2/2)

2.1 三层架构(你项目的核心分层)

复制代码
ROS2 真机/仿真 (srv/msg)  ←→  包装节点(HTTP DTO)  ←→  工具层(LLM schema)
   底层字段/单位/错误           隐藏细节/统一单位/     10 个原子工具
    mm/度、scaling、            语义化重命名            m/rad + base_link
    motor_faults...

原则:LLM 永远只接触最右层。 底层字段不出现在 tools= schema 里,单位换算只发生在左→中 或 中→左 的边界。

2.2 已核实的 Panthera 真实接口(去 panthera_interfaces/ 对着看)

  • srv/MoveToPose.srvx/y/z/roll/pitch/yaw + cartesian_path + velocity_scaling + acceleration_scalingbool success + string message
  • srv/MoveToJoint.srvfloat64[6] joint_angles + 两个 scaling → success/message
  • srv/GripperControl.srvstring action(open/close/half_open)→ success/message
  • srv/Enable.srvbool enable_requestbool enable_response
  • srv/GoZero.srvbool use_mit_modeint64 code + bool status
  • msg/ArmStatus.msgarm_enabledmotion_status(0/1/2)error_messagemotor_modes[6]motor_faults[6]joint_at_limit[6]gripper_positiongripper_fault
  • msg/EndPoseEuler.msgx/y/z/roll/pitch/yaw(float64)

2.3 映射表(docs/panthera-mapping.md 的雏形)

ROS 接口 ROS 字段 HTTP DTO 工具层 说明
MoveToPose.srv x/y/z/roll/pitch/yaw + 3 个隐藏字段 POST /arm/move_to_pose {position, orientation} move_to(pose) 隐藏 cartesian_path/scaling;m/rad 透传
MoveToJoint.srv joint_angles[6] POST /arm/move_to_joint(仅内部/调试) 不暴露给 LLM 关节角不进 LLM schema
GripperControl.srv action(open/close/half_open) POST /arm/gripper {action} grasp()→close、place()→open 动作名语义化
Enable.srv enable_request POST /arm/enable 状态机内部 使能不进 LLM
GoZero.srv use_mit_mode → code/status POST /arm/home home() 隐藏 use_mit_mode
ArmStatus.msg motion_status(0/1/2) 等 8 字段 GET /arm/status get_status() 裁剪:只留 joints/pose/gripper/mode;0/1/2→idle/moving/error
EndPoseEuler.msg x/y/z/roll/pitch/yaw status.pose get_pose(target) 与 scene.json 目标绑定

2.4 四条映射原则(面试也能讲)

  1. 只暴露"LLM 需要决策的语义"cartesian_pathvelocity_scalingmotor_faultsuse_mit_mode 这类执行细节,LLM 永远看不到。
  2. 单位换算只在 ROS 边界mm/度 ↔ m/rad 只发生在包装节点;LLM 只见 m/rad + base_link。
  3. 字段语义化重命名motion_status 的 0/1/2 → mode: "idle"/"moving"/"error"gripper_position"open"/"closed"
  4. 错误对齐 :ROS success=false + message → 映射到主题①的错误码矩阵,message 原文保留进 details 供审计。

2.5 代码:边界转换示例

复制代码
# 包装节点边界转换:DTO(m/rad) → ROS srv(中文注释)
def dto_to_move_pose(dto: dict) -> MoveToPoseRequest:
    """LLM 的 m/rad 位姿 → MoveToPose.srv 请求"""
    req = MoveToPoseRequest()
    req.x, req.y, req.z = dto["position"]["x"], dto["position"]["y"], dto["position"]["z"]
    req.roll, req.pitch, req.yaw = (
        dto["orientation"]["roll"],
        dto["orientation"]["pitch"],
        dto["orientation"]["yaw"],
    )
    # 隐藏字段:固定默认值,LLM 永远不接触
    req.cartesian_path = False
    req.velocity_scaling = 1.0
    req.acceleration_scaling = 1.0
    return req

def ros_status_to_dto(arm_status: ArmStatus) -> dict:
    """ArmStatus.msg → 工具层 get_status() 的 {joints, pose, gripper, mode}"""
    mode_map = {0: "idle", 1: "moving", 2: "error"}
    return {
        "arm_enabled": arm_status.arm_enabled,
        "mode": mode_map.get(arm_status.motion_status, "unknown"),
        "joints": list(arm_status.motor_modes),          # 示例:简化处理
        "pose": {
            "x": arm_status.pose.x, "y": arm_status.pose.y, "z": arm_status.pose.z,
            "roll": arm_status.pose.roll, "pitch": arm_status.pose.pitch, "yaw": arm_status.pose.yaw,
        },
        "gripper": "open" if arm_status.gripper_position > 0.5 else "closed",
        "fault": arm_status.error_message or None,
    }

主题③ 契约变更规则(晚上)

3.1 兼容 vs 破坏性判定表(docs/contract-change-rules.md 的核心)

变更类型 判定 示例
新增可选字段 ✅ 兼容 DTO 加 notes?: string
新增枚举值 ⚠️ 兼容(谨慎) mode 增加 "paused"(消费方要能容忍未知值)
新增必填字段 ❌ 破坏性 请求必须带 task_id
删除字段 ❌ 破坏性 删除 position.z
修改类型 ❌ 破坏性 int → string
修改单位/语义 ❌ 破坏性 m → mmroll 从度改弧度
修改取值范围 ❌ 破坏性(保守判定) radius 0.28 → 0.30
修改/删除错误码 ❌ 破坏性 重命名 OBJECT_NOT_FOUND

保守原则:拿不准就算破坏性。LLM 的 schema 一旦变化,老 prompt 缓存、已部署的 Agent、测试集都可能受影响。

3.2 代码:把判定规则"可执行化"

复制代码
# 简易契约变更判定器(中文注释)------ 用 OpenAPI/JSON Schema 的 diff 驱动
def classify_change(old: dict, new: dict) -> str:
    """返回 'compatible' 或 'breaking'(保守判定)"""
    # 1) 删了必填字段 → 破坏性
    for name in old.get("required", []):
        if name not in new.get("required", []):
            return "breaking"
    # 2) 字段被删除 / 类型变了 / 枚举收缩 → 破坏性
    for name, old_prop in old.get("properties", {}).items():
        new_prop = new.get("properties", {}).get(name)
        if new_prop is None:
            return "breaking"                                   # 删除字段
        if new_prop.get("type") != old_prop.get("type"):
            return "breaking"                                   # 类型变更
        if old_prop.get("enum") and old_prop.get("enum") != new_prop.get("enum"):
            return "breaking"                                   # 枚举变更
    # 3) 新增字段:可选 → 兼容;必填 → 破坏性
    for name, new_prop in new.get("properties", {}).items():
        if name not in old.get("properties", {}):
            if name in new.get("required", []):
                return "breaking"
    return "compatible"

3.3 五步变更流程

复制代码
1. 变更提案  写清「改什么 / 为什么 / 影响面」(一条 PR 描述即可)
2. 影响分析  扫所有消费方:LLM schema、前端、测试集、状态机、其他服务
3. 版本管理  破坏性 → 大版本;兼容 → 小版本(见 3.4)
4. 迁移计划  兼容期双写 / 废弃期 / 下线时间表
5. 回归验证  test_arm_contract.py 全绿 + demo_contract_validate.py + 端到端 20 任务

3.4 语义化版本策略(SemVer)

版本位 何时升 例子
MAJOR(大版本) 破坏性变更 1.x → 2.0(删除字段/改单位/改错误码)
MINOR(小版本) 向后兼容的新功能 1.0 → 1.1(新增可选字段、新端点)
PATCH(补丁) 修 bug 1.1.0 → 1.1.1

配合你的 contracts/ 目录:schema 文件用 git 管理,每次变更过 git diff 评审docs/contract-change-rules.md 里记录每一次"兼容/破坏性"判定结论。

10个Mock工具 + 防呆校验 + JSONL日志

纯仿真 Mock 工具层,上层 Agent Factory 调用的工具的软件模拟,完全不依赖 ROS、真机硬件 IQ‑9075,用于单元测试、Agent 逻辑调试,在不上实物机械臂的情况下验证大模型 Agent 任务编排逻辑

python 复制代码
"""mock_tools ------ 10 个原子工具的纯软件 Mock 实现(不接真机/ROS)。

统一约定(对齐《上层Demo实现方案 V1.0》§4.1/§4.4):
- 所有位姿参数单位 m/rad,坐标系 base_link;
- 单位转换(mm/度↔m/rad)只在真实包装节点做,Mock 阶段直接以 m/rad 输入;
- 工具层内建防呆校验:单位数量级(unit_guard)、目标名白名单、工作空间 radius<=0.28m / z 范围;
- 非法输入一律返回 {"success": False, "message": "原因"},绝不抛异常。
"""
from .logger import ToolCallLogger
from .config import (
    ROOT,
    load_json,
    load_tools_schema,
    load_scene,
    load_scripted_inputs,
    load_llm_config,
    FROZEN_CATEGORIES,
)
from .arm_state import MockArm
from .scene import SceneManager
from .tools import ToolLayer
from . import tool_dispatch

__all__ = [
    "ToolCallLogger", "ROOT", "load_json", "load_tools_schema", "load_scene",
    "load_scripted_inputs", "load_llm_config", "FROZEN_CATEGORIES",
    "MockArm", "SceneManager", "ToolLayer", "tool_dispatch",
]

纯内存的机械臂状态模拟器,属于 mock_tools 的核心数据模型 ,不调用 ROS、不跟 IQ‑9075 硬件通信。只在内存维护机械臂全部运行状态,供上层 Agent 工具调用做仿真测试。 对应架构图:Agent Factory → Tools/Skills内部的虚拟硬件状态。

python 复制代码
"""Mock 机械臂状态(纯软件模拟,不接真机/ROS)。

维护:末端位姿(base_link, m/rad)、6 关节角、夹爪开合、模式、夹持目标、播报记录。
"""
from __future__ import annotations

from copy import deepcopy
from typing import Any, Optional


class MockArm:
    """模拟机械臂的共享状态对象,工具层通过它读写「世界状态」。"""

    def __init__(self, scene_cfg: dict) -> None:
        self.home_pose: dict = deepcopy(scene_cfg["home_pose"])
        self.workspace: dict = deepcopy(scene_cfg["workspace"])
        self.table_z: float = float(scene_cfg["table_z"])
        self.pose: dict = deepcopy(self.home_pose)   # 当前末端位姿 (m/rad, base_link)
        self.joints: list[float] = [0.0] * 6         # 关节角(rad),Mock 恒为 0
        self.gripper: str = "open"                   # open | closed
        self.mode: str = "idle"                      # idle | moving | stopped | home
        self.last_action: Optional[str] = None
        self.held_target: Optional[str] = None       # 当前夹持的目标类别名
        self.utterances: list[str] = []              # speak 播报记录(Mock 不发声)
        self.motion_events: list[dict] = []          # 运动轨迹事件(审计用)

    def reset(self) -> None:
        """回到出厂状态(每个测试用例开始前调用,保证可复现)。"""
        self.pose = deepcopy(self.home_pose)
        self.joints = [0.0] * 6
        self.gripper = "open"
        self.mode = "idle"
        self.last_action = None
        self.held_target = None
        self.utterances = []
        self.motion_events = []

    def set_pose(self, pose: dict) -> None:
        self.pose = deepcopy(pose)
        self.mode = "idle"
        self.motion_events.append({"event": "move", "pose": deepcopy(pose)})

    def set_home(self) -> None:
        self.pose = deepcopy(self.home_pose)
        self.joints = [0.0] * 6
        self.mode = "home"
        self.motion_events.append({"event": "home", "pose": deepcopy(self.home_pose)})

    def status(self) -> dict:
        """get_status 工具的返回载荷。"""
        return {
            "success": True,
            "joints": list(self.joints),           # rad
            "pose": deepcopy(self.pose),           # base_link, m/rad
            "gripper": self.gripper,               # open/closed
            "mode": self.mode,
            "last_action": self.last_action,
            "held_target": self.held_target,
            "frame_id": "base_link",
        }

项目统一配置入口 ,全部 JSON 配置放在工程config/文件夹;Mock 仿真环境、未来真机环境共用这一套配置读取函数。上层 Agent、MockArm、SceneManager 全部从这里读取参数。

python 复制代码
"""配置加载:统一从工程根目录下的 config/ 读取 JSON 配置。"""
from __future__ import annotations

import json
from pathlib import Path

# 工程根目录(mock_tools 的上一级)
ROOT = Path(__file__).resolve().parent.parent

# 冻结类别表(目标名白名单,对齐《本地LLM选型与训练方案》§3.2 / §2.4)
FROZEN_CATEGORIES = ["红色杯子", "蓝色方块", "绿色瓶盖", "黄色纸盒"]

# 常见同义词(用于 Mock LLM 的目标名归一,真实 LLM 由提示词约束)
SYNONYMS = {
    "红杯": "红色杯子", "红杯子": "红色杯子", "那个红杯子": "红色杯子",
    "蓝块": "蓝色方块", "蓝方块": "蓝色方块", "蓝色块": "蓝色方块",
    "绿盖": "绿色瓶盖", "绿瓶盖": "绿色瓶盖",
    "黄盒": "黄色纸盒", "黄纸盒": "黄色纸盒",
}


def load_json(rel_path: str | Path) -> dict:
    """从工程根目录加载 JSON 文件(UTF-8,兼容 Windows 写入的 BOM)。"""
    with (ROOT / rel_path).open("r", encoding="utf-8-sig") as f:
        return json.load(f)


def load_tools_schema(rel_path: str = "config/tools_v1.json") -> list[dict]:
    """加载 §4.3 的 10 个工具 schema(OpenAI tools 格式数组)。"""
    data = load_json(rel_path)
    return data["tools"]


def load_scene(rel_path: str = "config/scene.json") -> dict:
    return load_json(rel_path)


def load_scripted_inputs(rel_path: str = "config/scripted_inputs.json") -> dict:
    return load_json(rel_path)


def load_llm_config(rel_path: str = "config/llm.json") -> dict:
    return load_json(rel_path)

Agent 工具调用的审计日志组件 ,属于 mock_tools 的基础设施;Mock 仿真阶段使用,真机环境完全可以复用这套日志逻辑 。 核心目的:完整留存每一次工具调用全量信息,用于调试 Agent 规划逻辑、定位工具调用错误、单元测试断言、事后回放整个分拣任务流程。 输出格式:JSON Lines(.jsonl),一行一条日志记录,方便后续脚本解析。

python 复制代码
"""工具层内建防呆校验(对齐《上层Demo实现方案 V1.0》§4.4 + §2.4(c))。

统一返回 (ok: bool, reason: str),供所有工具调用;非法输入返回「失败+原因」而非抛异常。
"""
from __future__ import annotations

import math
from typing import Any

# 单位防呆:把 mm 误当 m 传入会在数量级上立刻暴露(280mm=0.28m,而 280 远超 1.0)
UNIT_X_Y_RANGE = (-1.0, 1.0)   # x/y 数量级
UNIT_Z_RANGE = (0.0, 0.6)      # z 数量级(桌面以上)

POSE_KEYS = ("x", "y", "z", "rx", "ry", "rz")


def unit_guard(pose: dict, z_max: float = 0.6) -> bool:
    """防呆:位姿参数数量级必须是 m/rad(§2.4c)。通过返回 True。"""
    try:
        x, y, z = float(pose["x"]), float(pose["y"]), float(pose["z"])
    except (KeyError, TypeError, ValueError):
        return False
    if not (UNIT_X_Y_RANGE[0] <= x <= UNIT_X_Y_RANGE[1]):
        return False
    if not (UNIT_X_Y_RANGE[0] <= y <= UNIT_X_Y_RANGE[1]):
        return False
    if not (UNIT_Z_RANGE[0] <= z <= min(UNIT_Z_RANGE[1], z_max)):
        return False
    # 旋转量应在合理弧度范围内(数量级校验)
    for k in ("rx", "ry", "rz"):
        try:
            v = float(pose.get(k, 0.0))
        except (TypeError, ValueError):
            return False
        if not (-3.2 <= v <= 3.2):
            return False
    return True


def check_workspace(pose: dict, workspace: dict) -> tuple[bool, str]:
    """工作空间校验:radius<=0.28m、z∈[桌面,0.45m](§4.4 第 6 点)。"""
    try:
        x, y, z = float(pose["x"]), float(pose["y"]), float(pose["z"])
    except (KeyError, TypeError, ValueError):
        return False, f"位姿字段缺失或非数值: {pose}"
    max_radius = float(workspace.get("max_radius", 0.28))
    z_min = float(workspace.get("z_min", 0.0))
    z_max = float(workspace.get("z_max", 0.45))
    r = math.hypot(x, y)
    if r > max_radius + 1e-9:
        return False, f"超出可达半径: {r * 1000:.0f}mm > {max_radius * 1000:.0f}mm"
    if not (z_min - 1e-9 <= z <= z_max + 1e-9):
        return False, f"z 超出范围: {z * 1000:.0f}mm 不在 [{z_min * 1000:.0f}, {z_max * 1000:.0f}]mm"
    return True, "ok"


def validate_pose(pose: dict, workspace: dict) -> tuple[bool, str]:
    """组合防呆:字段齐全 + 数值类型 + m/rad 数量级 + 工作空间。"""
    if not isinstance(pose, dict):
        return False, "位姿必须是 JSON 对象"
    for k in POSE_KEYS:
        v = pose.get(k)
        if v is None:
            return False, f"缺少位姿字段: {k}"
        if not isinstance(v, (int, float)) or isinstance(v, bool):
            return False, f"位姿字段 {k} 必须是 number,收到 {type(v).__name__}"
    if not unit_guard(pose):
        return False, "单位防呆失败: 位姿数量级不符合 m/rad(x,y∈[-1,1], z∈[0,0.6])"
    ok, reason = check_workspace(pose, workspace)
    if not ok:
        return False, reason
    return True, "ok"


def check_target_whitelist(name: str, categories: list[str]) -> tuple[bool, str]:
    """目标名白名单校验:必须来自冻结类别表。"""
    if name in categories:
        return True, "ok"
    return False, f"目标 '{name}' 不在冻结类别表 {categories} 中,请先向用户确认"


def normalize_location(location: Any, place_locations: dict) -> tuple[bool, str, str]:
    """放置点归一化:default/box → 收纳盒(对齐 §4.3 place 的 enum)。"""
    if location is None or location in ("default", "box"):
        loc = "收纳盒"
    elif isinstance(location, str):
        loc = location
    else:
        return False, "放置点必须是字符串", ""
    if loc not in place_locations:
        return False, f"未知放置点 '{loc}',可用: {list(place_locations)}", ""
    return True, "ok", loc

Mock 仿真环境里的「虚拟视觉模块」 真机是 YOLO+3D 视觉输出物体类别、置信度、6D 位姿; Mock 模式下没有摄像头,SceneManager直接从scene.json读取物体真值,模拟视觉 detect 检测接口输出。 对应工具:detect_object工具底层就是调用本类;和MockArm配合:机械臂抓取物体时,同步更新物体grabbed/present状态。

python 复制代码
"""场景管理:从 config/scene.json 读取目标列表,维护抓取/放置状态。

detect() 只返回「在场且未被抓取」的目标;get_pose() 从场景取位姿。
"""
from __future__ import annotations

from copy import deepcopy
from typing import Any, Optional


class SceneManager:
    """维护当前场景中的目标(视觉识别结果的 Mock 来源)。"""

    def __init__(self, scene_cfg: dict) -> None:
        self.cfg: dict = scene_cfg
        self.categories: list[str] = list(scene_cfg["categories"])
        self.place_locations: dict = deepcopy(scene_cfg["place_locations"])
        self.workspace: dict = deepcopy(scene_cfg["workspace"])
        self.frame_id: str = scene_cfg.get("frame_id", "base_link")
        # 深拷贝,避免配置被修改;每个用例可重建
        self.targets: list[dict] = [deepcopy(t) for t in scene_cfg["targets"]]

    def reset(self) -> None:
        self.targets = [deepcopy(t) for t in self.cfg["targets"]]

    def get_target(self, class_name: str) -> Optional[dict]:
        for t in self.targets:
            if t["class"] == class_name:
                return t
        return None

    def present_targets(self) -> list[dict]:
        """在场且未被抓取的目标(detect 返回列表)。"""
        return [t for t in self.targets if t.get("present") and not t.get("grabbed")]

    def target_pose(self, class_name: str) -> Optional[dict]:
        t = self.get_target(class_name)
        return deepcopy(t["pose"]) if t else None

    def mark_grabbed(self, class_name: str) -> None:
        t = self.get_target(class_name)
        if t:
            t["grabbed"] = True

    def mark_placed(self, class_name: str) -> None:
        t = self.get_target(class_name)
        if t:
            t["present"] = False   # 已放置(离开桌面视野)
            t["grabbed"] = False

    def count_remaining(self) -> int:
        return len(self.present_targets())

    def detect_payload(self) -> dict:
        """detect() 的返回载荷:可配置的当前场景目标列表。"""
        items = []
        for t in self.present_targets():
            items.append({
                "class": t["class"],
                "confidence": float(t["confidence"]),
                "bbox": list(t["bbox"]),
                "pose": deepcopy(t["pose"]),       # base_link, m/rad
                "frame_id": self.frame_id,
            })
        return {"success": True, "message": "ok", "targets": items, "count": len(items)}

Agent 大模型和 ToolLayer 工具实现之间的中间转发层 Agent(Claude‑Code / DeepSeek‑v4‑flash)输出工具调用请求,不会直接调用ToolLayer内部方法;统一经过execute_tool()做分发。 价值:隔离大模型侧与工具实现,支持全局单会话模式,也支持单元测试每个用例传入独立ToolLayer实例,各个测试用例环境互不干扰

python 复制代码
"""工具分发:工具名 → ToolLayer.execute(对齐《本地LLM选型与训练方案》附录 B.1)。

默认使用全局 TOOL_LAYER(由 session 装配);也支持显式传入 layer,便于每用例独立环境。
"""
from __future__ import annotations

from typing import Optional

from .tools import ToolLayer, TOOL_NAMES

# 全局默认工具层(单会话场景);eval_agent 每用例会显式注入独立 layer
TOOL_LAYER: Optional[ToolLayer] = None


def set_tool_layer(layer: ToolLayer) -> None:
    global TOOL_LAYER
    TOOL_LAYER = layer


def execute_tool(name: str, args: dict, layer: Optional[ToolLayer] = None) -> dict:
    """执行工具;未知工具名返回失败(不抛异常)。"""
    layer = layer or TOOL_LAYER
    if layer is None:
        return {"success": False, "message": "工具层未初始化(请先创建 Session)"}
    if name not in TOOL_NAMES:
        return {"success": False, "message": f"未知工具: {name}"}
    return layer.execute(name, args)


__all__ = ["TOOL_LAYER", "set_tool_layer", "execute_tool", "TOOL_NAMES"]

Agent 大模型和 ToolLayer 工具实现之间的中间转发层 Agent(Claude‑Code / DeepSeek‑v4‑flash)输出工具调用请求,不会直接调用ToolLayer内部方法;统一经过execute_tool()做分发。 价值:隔离大模型侧与工具实现,支持全局单会话模式,也支持单元测试每个用例传入独立ToolLayer实例,各个测试用例环境互不干扰。

python 复制代码
"""10 个原子工具的 Mock 实现(不接真机/ROS,行为尽量贴近真实)。

对齐《上层Demo实现方案 V1.0》§4.2 工具总表:
detect / get_pose / grasp / place / move_to / stop / home / speak / listen / get_status

要点:
- 每个工具被调用时经 `_call` 统一包装:记录调用时间/入参/结果/耗时(ToolCallLogger)+ 控制台打印;
- 越界/非法输入返回 {"success": False, "message": "原因"},不抛异常;
- 支持「第 N 次失败」等脚本化行为(behavior: {"grasp_fail_calls": [1], ...}),便于测试重试逻辑;
- 固定 seed / 显式配置 → 行为可复现。
"""
from __future__ import annotations

import json
import math
import random
import time
from collections import defaultdict
from typing import Any, Callable, Optional

from .arm_state import MockArm
from .scene import SceneManager
from .logger import ToolCallLogger
from .safety import (
    check_target_whitelist,
    normalize_location,
    unit_guard,
    validate_pose,
)

# 工具名白名单(§4.2)
TOOL_NAMES = [
    "detect", "get_pose", "grasp", "place", "move_to",
    "stop", "home", "speak", "listen", "get_status",
]

# 模拟执行时延(秒):让日志中的耗时更贴近真实(可用 --no-latency 关闭以加速测试)
DEFAULT_LATENCY = {
    "detect": 0.020, "get_pose": 0.015, "grasp": 0.045, "place": 0.040,
    "move_to": 0.035, "stop": 0.005, "home": 0.030, "speak": 0.010,
    "listen": 0.020, "get_status": 0.005,
}

# 抓取容差:grasp 传入的位姿与场景目标位姿的距离小于该值才算「对准目标」
GRASP_MATCH_TOL = 0.03


class ToolLayer:
    """工具层:持有 Mock 机械臂/场景/脚本化语音,统一分发 + 日志。"""

    def __init__(
        self,
        arm: MockArm,
        scene: SceneManager,
        scripted_inputs: Optional[dict] = None,
        behavior: Optional[dict] = None,
        logger: Optional[ToolCallLogger] = None,
        initial_holding: Optional[str] = None,
        seed: int = 0,
        simulate_latency: bool = True,
        event_sink: Optional[Any] = None,
    ) -> None:
        self.arm = arm
        self.scene = scene
        self.behavior = behavior or {}
        self.logger = logger
        self.seed = seed
        self.simulate_latency = simulate_latency
        self.event_sink = event_sink          # 实时仪表盘事件发布(可空)
        self._rng = random.Random(seed)
        # 脚本化语音输入队列(模拟 ASR)
        self._inputs: list[str] = list((scripted_inputs or {}).get("inputs", []))
        self._loop = bool((scripted_inputs or {}).get("loop", True))
        self._input_idx = 0
        # 每个工具被调用次数(用于「第 N 次失败」脚本)
        self._counters: dict[str, int] = defaultdict(int)
        if initial_holding:
            self._seed_holding(initial_holding)

    # ---------- 统一分发 + 日志包装 ----------
    def execute(self, name: str, args: dict) -> dict:
        """按工具名分发(Agent 与状态机统一入口)。未知工具返回失败而非抛异常。"""
        fn = getattr(self, name, None)
        if fn is None:
            return self._finish(name, args, {"success": False, "message": f"未知工具: {name}"})
        if not isinstance(args, dict):
            return self._finish(name, args, {"success": False, "message": "工具参数必须是 JSON 对象"})
        return self._call(name, args, fn)

    def _call(self, name: str, args: dict, fn: Callable[..., dict]) -> dict:
        """统一包装:计数 → 模拟时延 → 执行 → 记录日志(时间戳/入参/结果/耗时)。

        若配置了 event_sink(实时仪表盘),先发布 tool_start("正在调用"),
        执行完发布 tool_end + 机械臂状态快照("被调用的状态"实时可见)。
        """
        self._counters[name] += 1
        call_no = self._counters[name]
        if self.event_sink:
            self.event_sink.publish("tool_start", {"tool": name, "args": args, "call_no": call_no})
        t0 = time.perf_counter()
        if self.simulate_latency:
            time.sleep(DEFAULT_LATENCY.get(name, 0.005))   # 模拟执行时延(计入耗时)
        try:
            result = fn(**args)
        except TypeError as e:                       # 参数不匹配 → 失败,不抛异常
            result = {"success": False, "message": f"工具 '{name}' 参数错误: {e}"}
        except Exception as e:                       # 兜底:工具内部异常也转成失败返回
            result = {"success": False, "message": f"工具 '{name}' 执行异常: {e}"}
        duration_ms = (time.perf_counter() - t0) * 1000.0
        if self.logger:
            self.logger.log(name, args, result, duration_ms)
        if self.event_sink:
            self.event_sink.publish("tool_end", {
                "tool": name, "args": args, "result": result,
                "duration_ms": round(duration_ms, 3), "call_no": call_no,
            })
            self.event_sink.publish("status", self.arm.status())
        return result

    def _finish(self, name: str, args: dict, result: dict) -> dict:
        """未进入正常执行路径时也记录日志(保持可审计)。"""
        duration_ms = 0.0
        if self.logger:
            self.logger.log(name, args, result, duration_ms)
        if self.event_sink:
            self.event_sink.publish("tool_start", {"tool": name, "args": args, "call_no": self._counters[name]})
            self.event_sink.publish("tool_end", {
                "tool": name, "args": args, "result": result,
                "duration_ms": round(duration_ms, 3), "call_no": self._counters[name],
            })
            self.event_sink.publish("status", self.arm.status())
        return result

    def _scripted_fail(self, tool: str, what: str) -> Optional[dict]:
        """「第 N 次失败」脚本:behavior={"grasp_fail_calls": [1,3]} 表示第 1、3 次调用失败。"""
        fail_calls = self.behavior.get(f"{tool}_fail_calls", [])
        if self._counters[tool] in fail_calls:
            return {"success": False,
                    "message": f"脚本化{tool}失败(第 {self._counters[tool]} 次调用,fail_calls={fail_calls})"}
        return None

    # ---------- 10 个原子工具 ----------
    def detect(self) -> dict:
        """detect():返回可配置的当前场景目标列表(读 scene.json)。"""
        return self.scene.detect_payload()

    def get_pose(self, target: str) -> dict:
        """get_pose(目标):类别表内 → base_link 下 m/rad 位姿;不在表内 → 失败。"""
        ok, reason = check_target_whitelist(target, self.scene.categories)
        if not ok:
            return {"success": False, "message": reason}
        t = self.scene.get_target(target)
        if t is None:
            return {"success": False, "message": f"场景中未找到目标 '{target}'"}
        if t.get("grabbed"):
            return {"success": False, "message": f"目标 '{target}' 已被抓取,无法再次获取位姿"}
        pose = self.scene.target_pose(target)
        return {"success": True, "message": "ok", "target": target,
                "pose": pose, "frame_id": "base_link"}

    def grasp(self, x: float, y: float, z: float,
              rx: float = 0.0, ry: float = 0.0, rz: float = 0.0) -> dict:
        """grasp(位姿):模拟移动到目标位姿 → 闭合夹爪抓取。越界/单位异常返回失败。"""
        pose = {"x": x, "y": y, "z": z, "rx": rx, "ry": ry, "rz": rz}
        ok, reason = validate_pose(pose, self.scene.workspace)
        if not ok:
            return {"success": False, "message": f"grasp 位姿校验失败: {reason}"}
        fail = self._scripted_fail("grasp", "抓取")
        if fail:
            return fail
        # 在目标位姿附近找最近的在场目标(模拟「对准目标再抓」)
        nearest, dist = self._nearest_present_target(pose)
        if nearest is None or dist > GRASP_MATCH_TOL:
            return {"success": False,
                    "message": f"目标位姿附近({dist * 1000:.0f}mm)未检测到可抓取目标"}
        self.arm.pose = dict(pose)
        self.arm.gripper = "closed"
        self.arm.held_target = nearest["class"]
        self.arm.last_action = f"grasp:{nearest['class']}"
        self.scene.mark_grabbed(nearest["class"])
        return {"success": True, "message": f"已抓取 {nearest['class']}",
                "target": nearest["class"], "pose": pose}

    def place(self, location: str = "收纳盒") -> dict:
        """place():把当前夹持物放置到指定放置点(默认收纳盒),先到位再张爪。"""
        ok, reason, loc = normalize_location(location, self.scene.place_locations)
        if not ok:
            return {"success": False, "message": reason}
        if self.arm.gripper != "closed" or not self.arm.held_target:
            return {"success": False, "message": "夹爪未夹持物体,无法放置"}
        fail = self._scripted_fail("place", "放置")
        if fail:
            return fail
        target = self.arm.held_target
        place_pose = self.scene.place_locations[loc]
        self.arm.pose = dict(place_pose)
        self.arm.gripper = "open"
        self.arm.held_target = None
        self.arm.last_action = f"place:{loc}:{target}"
        self.scene.mark_placed(target)
        return {"success": True, "message": f"已把 {target} 放到 {loc}", "location": loc}

    def move_to(self, x: float, y: float, z: float,
                rx: float = 0.0, ry: float = 0.0, rz: float = 0.0) -> dict:
        """move_to(位姿):移动末端到位姿(m/rad, base_link)。越界返回失败。"""
        pose = {"x": x, "y": y, "z": z, "rx": rx, "ry": ry, "rz": rz}
        ok, reason = validate_pose(pose, self.scene.workspace)
        if not ok:
            return {"success": False, "message": f"move_to 位姿校验失败: {reason}"}
        fail = self._scripted_fail("move_to", "移动")
        if fail:
            return fail
        self.arm.set_pose(pose)
        self.arm.last_action = "move_to"
        return {"success": True, "message": "ok", "pose": pose}

    def stop(self) -> dict:
        """stop():软急停,最高优先级,记录状态。"""
        self.arm.mode = "stopped"
        self.arm.last_action = "stop"
        return {"success": True, "message": "已急停", "mode": "stopped"}

    def home(self) -> dict:
        """home():回安全原点。"""
        self.arm.set_home()
        self.arm.last_action = "home"
        return {"success": True, "message": "已回原点", "pose": self.arm.home_pose}

    def speak(self, text: str) -> dict:
        """speak(文本):记录播报文本(Mock 不发声)。"""
        if not isinstance(text, str) or not text.strip():
            return {"success": False, "message": "speak 文本不能为空"}
        self.arm.utterances.append(text)
        self.arm.last_action = f"speak:{text[:20]}"
        return {"success": True, "message": "ok", "text": text}

    def listen(self) -> dict:
        """listen():从 scripted_inputs.json 取一条脚本化语音输入(模拟 ASR)。"""
        if not self._inputs:
            return {"success": False, "message": "scripted_inputs 为空,无语音输入"}
        text = self._inputs[self._input_idx % len(self._inputs)]
        self._input_idx += 1
        self.arm.last_action = "listen"
        return {"success": True, "message": "ok", "text": text, "engine": "mock_asr"}

    def get_status(self) -> dict:
        """get_status():返回 mock 的 {joints, pose, gripper, mode, ...}。"""
        return self.arm.status()

    # ---------- 内部辅助 ----------
    def _nearest_present_target(self, pose: dict):
        """返回 (最近在场目标, 欧氏距离)。"""
        best, best_d = None, float("inf")
        for t in self.scene.present_targets():
            p = t["pose"]
            d = math.dist([p["x"], p["y"], p["z"]], [pose["x"], pose["y"], pose["z"]])
            if d < best_d:
                best, best_d = t, d
        return best, best_d

    def _seed_holding(self, class_name: str) -> None:
        """测试辅助:预置夹爪夹持某目标(用于 place 单工具用例)。"""
        t = self.scene.get_target(class_name)
        if t:
            self.arm.gripper = "closed"
            self.arm.held_target = class_name
            self.scene.mark_grabbed(class_name)

    def as_json(self) -> str:
        """把当前工具层状态序列化为 JSON(审计/复现用)。"""
        return json.dumps({
            "arm": {"pose": self.arm.pose, "gripper": self.arm.gripper,
                    "mode": self.arm.mode, "held_target": self.arm.held_target},
            "scene": {"targets": self.scene.targets},
            "counters": dict(self._counters),
        }, ensure_ascii=False, indent=2)
相关推荐
搭贝1 小时前
国资报送责任制怎么建?三级责任矩阵设计
android·数据库·人工智能·线性代数·低代码·矩阵·制造
xier_ran16 小时前
【infra之路】AWQ 详解:激活感知权重保护,让 W4A16 量化精度超越 GPTQ
线性代数·算法·机器学习·量化·infra
远航计算机20 小时前
GEO自诊断实操手册:你的内容为什么AI搜不到?
人工智能·矩阵·aigc·媒体
晓晓_za8986681 天前
Geo 优化源码二次开发:自定义地域规则改造实操文档
java·开发语言·搜索引擎·ci/cd·矩阵
BSD_HY1 天前
薄膜开关矩阵扫描电路设计中的防鬼键措施
人工智能·算法·矩阵·人机交互·薄膜开关·源头工厂·深圳工厂
kyle~1 天前
点云配准--- 迭代最近点 ICP 求解
线性代数·算法·机器学习
kyle~1 天前
线性代数矩阵分解 --- QR分解
线性代数·矩阵
lilian2332 天前
Harmony os 技术实战|拼豆制图43:压缩字符矩阵上线前如何拦住错位图纸
开发语言·前端·华为·矩阵·harmonyos
ASKED_20192 天前
从矩阵到秩:理解大模型参数与 LoRA 微调
线性代数·矩阵