openbot
§ · Nested detail page

Human Handoff 深度解析

这一页只看 OpenBot 的一个局部模块,用真实源码行号解释它承担的契约、失败路径和与主架构的关系。

§ 00 · IMPORT CHAIN

导入链与契约

▸状态机可单测

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 就覆盖拒绝路径。

src/agent-computer/src/control.ts · L17-85TypeScript
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    },
§ 01 · CORE IMPL

核心实现,逐段拆

▸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 动作。

src/agent-computer/src/control.ts · L87-127TypeScript
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 供应后清空请求。

src/agent-computer/src/control.ts · L129-203TypeScript
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  };
§ 02 · KEY FLOW

关键流程

▸接管流程

Bot stuck → requestHelp(reason) → UI shows request → human take() → Bot actions refused → human release() → Bot may act again

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 但不记录值。

§ 03 · EDGE CASES

边界 / 失败模式

▸测试覆盖拒绝路径

  • 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。
Generated · 2026-08-21 Source · CopilotKit/OpenBot ← 返回 Trending 索引