Human Handoff 深度解析
这一页只看 OpenBot 的一个局部模块,用真实源码行号解释它承担的契约、失败路径和与主架构的关系。
导入链与契约
▸状态机可单测
human handoff 的关键是状态机独立于 Playwright。ControlState 定义在 agent-computer/src/control.ts:17,ControlError 是 agent-computer/src/control.ts:43,ControlRequestError 是 agent-computer/src/control.ts:51;常量 NO_SECRET_PENDING 在 agent-computer/src/control.ts:59,状态机工厂从 agent-computer/src/control.ts:72 开始,get 副本是 agent-computer/src/control.ts:83,requestHelp 和 requestSecret 分别是 agent-computer/src/control.ts:93 与 agent-computer/src/control.ts:106 显示 control.ts 只定义状态、错误、工厂和操作,浏览器执行留在 agent-computer index。这样 agent-computer/tests/control.test.ts:1 的 bun tests 可以不启动 Chrome 就覆盖拒绝路径。
17export type ControlState = { 18 holder: "bot" | "human"; 19 since: string; 20 /** Why the Bot asked, so the person knows what they are being handed. Set when the Bot requests. */ 21 reason?: string; 22 /** True once the Bot has asked for help and no person has taken the wheel yet. */ 23 requested: boolean; 24 /** 25 * A secret the Bot is waiting for, described by its label only. 26 * 27 * Secret entry is scoped rather than a full takeover. The Bot names the field, says what it needs, 28 * and the person types into a masked box that goes straight to the page. 29 * 30 * The label is all that is ever stored. The value passes through one request and is not kept here, 31 * not returned, and not on any path the model reads. 32 */ 33 secretWanted?: string; 34 /** 35 * Which field the secret goes in, as a ref from the Bot's snapshot. 36 * 37 * Required so a secret cannot be sent to whichever field happens to have focus. 38 */ 39 secretRef?: string; 40 secretSnapshotId?: number; 41}; 42 43/** Refusal because a person is driving. Distinct from a failure, so the Bot can be told to wait. */ 44export class ControlError extends Error { 45 constructor(message: string) { 46 super(message); 47 this.name = "ControlError"; 48 } 49} 50 51/** What a caller must say to ask for a secret. Rejected as a request error, not thrown. */ 52export class ControlRequestError extends Error { 53 constructor(message: string) { 54 super(message); 55 this.name = "ControlRequestError"; 56 } 57} 58 59export const NO_SECRET_PENDING = "Nothing is waiting for a secret."; 60export const HUMAN_HAS_CONTROL = 61 "A person has control of the computer right now. Wait for them to hand it back before acting."; 62export const TAKE_CONTROL_FIRST = 63 "Take control before driving the computer yourself."; 64 65/** 66 * The wheel, as a state machine. 67 * 68 * A factory rather than a module-level `let` so a test can have its own, and so two of these cannot 69 * accidentally share state. `now` is injected for the same reason: `since` is part of the published 70 * state, and a test that cannot control the clock has to either skip it or match it loosely. 71 */ 72export function createControl( 73 now: () => string = () => new Date().toISOString(), 74) { 75 let state: ControlState = { 76 holder: "bot", 77 since: now(), 78 requested: false, 79 }; 80 81 return { 82 /** The current state, as the surface polls it. A copy, so a caller cannot mutate the machine. */ 83 get(): ControlState { 84 return { ...state }; 85 },
核心实现,逐段拆
▸Bot 请求帮助,不等于人已经接管
空 ref 在 agent-computer/src/control.ts:111 拒绝,secret 状态写入从 agent-computer/src/control.ts:116 开始;pendingSecret 在 agent-computer/src/control.ts:135 返回受限字段,secretSupplied 在 agent-computer/src/control.ts:146 清理;take/release 分别是 agent-computer/src/control.ts:162 和 agent-computer/src/control.ts:180,Bot 行动拒绝与 human driving 判断是 agent-computer/src/control.ts:195、agent-computer/src/control.ts:200 说明 state machine 的细节:agent-computer/src/control.ts:93 只设置 requested/reason,agent-computer/src/control.ts:162 才切 holder,agent-computer/src/control.ts:195 在 human holder 下拒绝 Bot 动作。
87 /** 88 * The Bot asking for help. 89 * 90 * It does not take control: it says it is stuck and why, and a person decides. A Bot that could 91 * hand itself to a human could also hand a human a page they never asked to see. 92 */ 93 requestHelp(reason: unknown): ControlState { 94 state = { 95 ...state, 96 requested: true, 97 reason: 98 typeof reason === "string" && reason.trim() 99 ? reason.trim() 100 : "The assistant needs a person to continue.", 101 }; 102 return this.get(); 103 }, 104 105 /** The Bot asking for one value it must not be told, naming the field it goes in. */ 106 requestSecret(input: { 107 label?: unknown; 108 ref?: unknown; 109 snapshotId?: unknown; 110 }): ControlState { 111 if (typeof input.ref !== "string" || !input.ref.trim()) { 112 throw new ControlRequestError( 113 "Say which field the value goes in, using a ref from your snapshot.", 114 ); 115 } 116 state = { 117 ...state, 118 secretWanted: 119 typeof input.label === "string" && input.label.trim() 120 ? input.label.trim() 121 : "the value this page is asking for", 122 secretRef: input.ref.trim(), 123 secretSnapshotId: 124 typeof input.snapshotId === "number" ? input.snapshotId : undefined, 125 }; 126 return this.get(); 127 },
▸secret 是一次性、定字段、无值状态
agent-computer/src/control.ts:106 要求 label/ref/snapshotId,agent-computer/src/control.ts:111 对空 ref 抛 request error,agent-computer/src/control.ts:135 只返回 ref 和 snapshotId,agent-computer/src/control.ts:146 供应后清空请求。
129 /** 130 * The pending secret request, or null. 131 * 132 * Read before typing so the caller can refuse when nothing asked for one: this is what keeps the 133 * masked box from being a general-purpose way to type into the page. 134 */ 135 pendingSecret(): { ref: string; snapshotId?: number } | null { 136 if (!state.secretWanted || !state.secretRef) return null; 137 return { ref: state.secretRef, snapshotId: state.secretSnapshotId }; 138 }, 139 140 /** 141 * The secret landed, so the request is closed. 142 * 143 * Called only after the value reaches the field. A failure leaves the request open so the person 144 * can try again. 145 */ 146 secretSupplied(): void { 147 state = { 148 ...state, 149 secretWanted: undefined, 150 secretRef: undefined, 151 secretSnapshotId: undefined, 152 }; 153 }, 154 155 /** 156 * A person taking the wheel. 157 * 158 * `reason` survives, because it is the thing they were just asked to do. Any pending secret is 159 * cleared: a person with full browser control can type the password into the page, and a masked 160 * box left open behind them no longer corresponds to an active request. 161 */ 162 take(): ControlState { 163 state = { 164 holder: "human", 165 since: now(), 166 reason: state.reason, 167 requested: false, 168 }; 169 return this.get(); 170 }, 171 172 /** 173 * A person handing back. 174 * 175 * `reason` is dropped: it described the thing the person was asked to do, and once they have done 176 * it, leaving it set would have the surface still showing the old request. Any pending secret goes 177 * with it, a person who took the whole wheel and handed it back has dealt with the login, and a 178 * secret box left open afterwards is asking for a password nothing is waiting for. 179 */ 180 release(): ControlState { 181 state = { 182 holder: "bot", 183 since: now(), 184 requested: false, 185 }; 186 return this.get(); 187 }, 188 189 /** 190 * The Bot may not act while a person holds the wheel. 191 * 192 * Refused rather than queued. A queued click lands after the person has moved on and is worse than 193 * a refusal, which the Bot can explain and wait out. 194 */ 195 assertBotMayAct(): void { 196 if (state.holder === "human") throw new ControlError(HUMAN_HAS_CONTROL); 197 }, 198 199 /** Whether a person's input should be applied. The socket being open is not permission. */ 200 humanMayDrive(): boolean { 201 return state.holder === "human"; 202 }, 203 };
关键流程
▸接管流程
server gateway 也把接管写审计:server/src/computer/gateway.ts:428 记录 help requested,server/src/computer/gateway.ts:440 记录 control taken,server/src/computer/gateway.ts:452 记录 release,server/src/computer/gateway.ts:526 到 server/src/computer/gateway.ts:548 记录 secret request/supply 但不记录值。
边界 / 失败模式
▸测试覆盖拒绝路径
agent-computer/tests/control.test.ts:103验证 open socket 不是驾驶权限。agent-computer/tests/control.test.ts:150验证 secret request 必须命名字段。agent-computer/tests/control.test.ts:196验证 supplied secret 会关闭请求。agent-computer/tests/control.test.ts:225验证 secret value 不会进入 state。