iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0

開場故事

如果一間公司收到一個需求,最怕的不是沒人做,而是大家都以為自己該做。

老闆看到信就自己回、工程師看到信就自己改、行政看到信就自己排、每個人都想直接把事情處理掉。短時間看起來很勤快,長時間就是災難。

OpenClaw 的做法剛好相反。

它不是先問「我能不能做」,而是先問:

  • 這件事該由誰接?
  • 這個角色有沒有被允許接?
  • 這個任務要送到哪個 session?
  • 這筆輸出要怎麼回到人能看懂的地方?

第 3 天我們看的是入口怎麼接住任務。第 4 天就要再往下一層,看它怎麼把任務真正分派出去,而且不是靠想像,而是看 source code 怎麼做。

這一篇我會把設定當成引子,但主體放在實作本身。

今天要解的問題

  • OpenClaw 是怎麼驗證一個任務可以派給誰?
  • allowAgents 只是設定,還是會真的影響執行?
  • sessions_spawn 在 source code 裡怎麼收件、過濾、放行?
  • 訊息送達與 session 路由,又是怎麼被處理的?
  • 為什麼分派不是單純「叫別人做」,而是一整段流程?

先講結論

OpenClaw 的分派不是一個動作,而是兩段流程:

  1. 先在 subagent spawn 這邊確認「誰可以被叫來做事」
  2. 再在 delivery / routing 這邊確認「做完之後怎麼把結果送出去」

所以如果只看設定檔,你只會知道有名單。
但如果看 source code,你才會知道:

  • 名單怎麼被 normalize
  • 名單怎麼跟實際 agent registry 交集
  • 不合法時怎麼報錯
  • 任務送達時怎麼決定 channel、account、thread、session key

也就是說,設定只是入口,真正的分派邏輯在 code 裡。

架構總覽

如果把這一段拆成白話版,大概是這樣:

使用者需求
  -> main 接住
  -> 決定要不要分派
  -> subagent spawn code 檢查誰可被叫出來
  -> delivery / routing code 決定工作要送去哪裡
  -> 任務執行
  -> 結果回傳或送達

這裡最重要的觀念是:

  • allowAgents 不是裝飾品
  • subagents 不是說「有這些人」而已
  • route / delivery 不是純輸出,它們是在決定結果要落到哪個 session

所以第 4 天真正要看的不是「有沒有設定」,而是「設定怎麼被程式吃進去,然後變成規則」。

原始碼節錄

1. 先看設定長什麼樣,但只當引子

openclaw.json 裡面,main 的確有一段子代理允許清單:

📄 文件:docs/tools/pdf.md:170-181

{
  "id": "main",
  "model": "openai/gpt-5.4-mini",
  "subagents": {
    "allowAgents": [
      "research",
      "kaijin",
      "diablo",
      "shion",
      "gobta",
      "souei",
      "luminous",
      "adalman",
      "benimaru"
    ]
  }
}

這段本身只是在描述政策,還不夠。
真正關鍵的是,這個 policy 會怎麼被 source code 讀進去、整理、驗證。

2. allowAgents 怎麼被整理成可用的目標

下面這段來自 /opt/homebrew/lib/node_modules/openclaw/dist/subagent-spawn-plan-CsAYyEim.js,核心是把 allowAgents 轉成可比較的 policy。

📄 原始碼:src/agents/subagent-target-policy.ts:32-78

function normalizeAllowAgents(allowAgents) {
	if (!Array.isArray(allowAgents)) return {
		configured: false,
		allowAny: false,
		allowedIds: []
	};
	const allowedIds = allowAgents.map((value) => value.trim()).filter((value) => value && value !== "*").map((value) => normalizeAgentId(value)).filter(Boolean);
	return {
		configured: true,
		allowAny: allowAgents.some((value) => value.trim() === "*"),
		allowedIds: sortUniqueStrings(allowedIds)
	};
}

function resolveSubagentAllowedTargetIds(params) {
	const requesterAgentId = normalizeAgentId(params.requesterAgentId);
	const policy = normalizeAllowAgents(params.allowAgents);
	if (!policy.configured) return {
		allowAny: false,
		allowedIds: requesterAgentId ? [requesterAgentId] : []
	};
	if (policy.allowAny) {
		const configuredIds = Array.from(normalizeConfiguredAgentIds(params.configuredAgentIds));
		if (requesterAgentId) configuredIds.push(requesterAgentId);
		return {
			allowAny: true,
			allowedIds: sortUniqueStrings(configuredIds)
		};
	}
	return {
		allowAny: false,
		allowedIds: filterConfiguredAllowedIds({
			allowedIds: policy.allowedIds,
			configuredAgentIds: params.configuredAgentIds
		}).toSorted((a, b) => a.localeCompare(b))
	};
}

這段在做的事,其實很直接:

  • 先把 allowAgents 標準化
  • 再把它跟實際已配置的 agent 名單做交集
  • 沒配置的話,預設只允許 requester 自己
  • 如果用了 *,才代表放寬成 allow any

這代表 OpenClaw 並不是看到 allowAgents 就直接照單全收。
它會先清理輸入,再跟實際 registry 比對,最後才決定誰真的能接。

這就是「設定有寫,不代表能執行」的第一層。

3. 真正的放行與拒絕是在這裡

同一個檔案裡,下一段是驗證單一目標是否真的能派出去:

📄 原始碼:src/agents/subagent-target-policy.ts:57-106

function resolveSubagentTargetPolicy(params) {
	const requesterAgentId = normalizeAgentId(params.requesterAgentId);
	const targetAgentId = normalizeAgentId(params.targetAgentId);
	if (!params.requestedAgentId?.trim() && targetAgentId === requesterAgentId) return { ok: true };
	const allowed = resolveSubagentAllowedTargetIds({
		requesterAgentId,
		allowAgents: params.allowAgents,
		configuredAgentIds: params.configuredAgentIds
	});
	if (allowed.allowedIds.includes(targetAgentId)) return { ok: true };
	const allowedText = allowed.allowedIds.length > 0 ? allowed.allowedIds.join(", ") : "none";
	const policy = normalizeAllowAgents(params.allowAgents);
	if (allowed.allowAny || policy.allowedIds.includes(targetAgentId)) return {
		ok: false,
		allowedText,
		error: `agentId "${targetAgentId}" is not in the configured agent registry (allowed: ${allowedText})`
	};
	return {
		ok: false,
		allowedText,
		error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`
	};
}

這段就是實際的 gate。

它會先看:

  • 你是不是沒指定 target,只是自己 spawn 自己
  • 目標 agent 有沒有在允許名單裡
  • 名單裡有沒有真的存在這個 agent

如果不行,就直接回 error。

這裡很重要,因為它把「政策」變成「執行時檢查」了。
不是設定看起來可以,而是跑到這一關真的可以。

4. 任務不是只分給誰,還要知道怎麼送到哪裡

接下來看 /opt/homebrew/lib/node_modules/openclaw/dist/agent-delivery-BE_Ki_cN.js 裡的 delivery / routing 邏輯。

📄 原始碼:src/infra/outbound/agent-delivery.ts:86-163

function resolveAgentDeliveryPlan(params) {
	const requestedRaw = normalizeOptionalString(params.requestedChannel) ?? "";
	const requestedChannel = (requestedRaw ? normalizeMessageChannel(requestedRaw) : void 0) || "last";
	const explicitTo = normalizeOptionalString(params.explicitTo) ?? void 0;
	const normalizedTurnSource = params.turnSourceChannel ? normalizeMessageChannel(params.turnSourceChannel) : void 0;
	const turnSourceChannel = normalizedTurnSource && isDeliverableMessageChannel(normalizedTurnSource) ? normalizedTurnSource : void 0;
	const turnSourceTo = normalizeOptionalString(params.turnSourceTo) ?? void 0;
	const turnSourceAccountId = normalizeAccountId(params.turnSourceAccountId);
	const turnSourceThreadId = params.turnSourceThreadId != null && params.turnSourceThreadId !== "" ? params.turnSourceThreadId : void 0;
	const baseDelivery = resolveSessionDeliveryTarget({
		entry: params.sessionEntry,
		requestedChannel: requestedChannel === "webchat" ? "last" : requestedChannel,
		explicitTo,
		explicitThreadId: params.explicitThreadId,
		turnSourceChannel,
		turnSourceTo,
		turnSourceAccountId,
		turnSourceThreadId
	});
	const resolvedChannel = (() => {
		if (requestedChannel === "webchat") return INTERNAL_MESSAGE_CHANNEL;
		if (requestedChannel === "last") {
			if (baseDelivery.channel && baseDelivery.channel !== "webchat") return baseDelivery.channel;
			return INTERNAL_MESSAGE_CHANNEL;
		}
		if (isGatewayMessageChannel(requestedChannel)) return requestedChannel;
		if (baseDelivery.channel && baseDelivery.channel !== "webchat") return baseDelivery.channel;
		return INTERNAL_MESSAGE_CHANNEL;
	})();
	const deliveryTargetMode = explicitTo ? "explicit" : isDeliverableMessageChannel(resolvedChannel) ? "implicit" : void 0;
	const resolvedAccountId = normalizeAccountId(params.accountId) ?? (deliveryTargetMode === "implicit" ? baseDelivery.accountId : void 0);
	let resolvedTo = explicitTo;
	if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel) && resolvedChannel === baseDelivery.lastChannel) resolvedTo = baseDelivery.lastTo;
	return {
		baseDelivery,
		resolvedChannel,
		resolvedTo,
		resolvedAccountId,
		resolvedThreadId: baseDelivery.threadId,
		deliveryTargetMode
	};
}

這段 code 看起來很像在處理一堆聊天系統的雜事,但本質只有一件事:

工作做完以後,要送到哪一條通道、哪個 account、哪個 thread。

它會先整理:

  • 想送到哪個 channel
  • 有沒有指定 to
  • 來源是什麼
  • thread id 是什麼
  • account id 要沿用還是重新找

這就是「接收任務」之後的另一半:送達。

如果前面的 subagent-spawn-plan 解的是「誰可接」,這裡解的是「結果往哪裡回」。

5. 送達不是只看一條路,還會做 session route

同一個檔案後面還有一段更重的處理:

📄 原始碼:src/infra/outbound/agent-delivery.ts:176-325

async function resolveAgentDeliveryPlanWithSessionRoute(params) {
	const plan = resolveAgentDeliveryPlan(params);
	const { resolvedChannel, resolvedTo } = plan;
	if (!params.wantsDelivery || !resolvedTo || !isDeliverableMessageChannel(resolvedChannel)) return plan;
	const plugin = resolveOutboundChannelPlugin({
		channel: resolvedChannel,
		cfg: params.cfg,
		allowBootstrap: true
	});
	const hasPluginSessionRoute = Boolean(plugin?.messaging?.resolveOutboundSessionRoute);
	if (!hasPluginSessionRoute && params.sessionRouteMode !== "allow-fallback") return plan;
	const resolvedAccountId = plan.resolvedAccountId ?? (plugin && params.sessionRouteMode === "allow-fallback" ? resolveChannelDefaultAccountId({
		plugin,
		cfg: params.cfg
	}) : void 0);
	const normalizedTarget = resolveOutboundTarget({
		channel: resolvedChannel,
		to: resolvedTo,
		cfg: params.cfg,
		accountId: routedPlan.resolvedAccountId,
		mode: routedPlan.deliveryTargetMode ?? "explicit"
	});
	...
	return {
		...routedPlan,
		resolvedSessionKey: selectedRoute.sessionKey,
		resolvedTo: hasPluginSessionRoute ? selectedRoute.to : resolvedSessionRouteTarget?.to ?? sessionRouteTarget,
		resolvedThreadId: selectedRoute.threadId ?? (routedPlan.deliveryTargetMode === "explicit" ? explicitThreadId : routedPlan.resolvedThreadId)
	};
}

這段的重點是:OpenClaw 不只知道「送到哪個 channel」,還會進一步找對應的 session route。

也就是說,它不是簡單把訊息丟出去而已,而是在問:

  • 這個 channel 有沒有 plugin 可以處理 outbound route?
  • 如果有,session key 要怎麼算?
  • 如果沒有,能不能 fallback?
  • to 要不要重新標準化?
  • thread id 要不要沿用?

這就是完整的「接收 -> 處理 -> 路由 -> 送達」鏈條。

白話拆解

我把這段想成一間公司真的在派工。

1. allowAgents 是名單,不是命令

很多人看設定會想說:

「既然寫了 allowAgents,那是不是就代表可以叫這些人?」

不完全是。

因為 source code 會再做兩層事情:

  • 先把名字整理乾淨
  • 再跟實際存在的 agent registry 比對

所以這不是「有寫就算」,而是「有寫,還要真的存在,還要真的被允許」。

2. sessions_spawn 像人事窗口

你可以把 resolveSubagentTargetPolicy 想成一個人事窗口。

窗口先看:

  • 你要叫誰
  • 這個人是不是公司內的人
  • 這個角色是不是允許接這個案
  • 如果不行,錯在哪裡

如果不符合規則,它不會勉強幫你接單,而是直接退回。

這很像成熟的組織,不會因為「想要快」就跳過權限。

3. delivery / routing 像物流系統

任務接了之後,真正麻煩的是結果怎麼回去。

這時候不是只看一個「訊息要發出去」就完事,而是要考慮:

  • 哪個 channel
  • 哪個 account
  • 哪個 thread
  • 是不是要走 plugin route
  • 要不要 fallback

這就像物流出貨。

不是包裹交出去就好了,還要知道地址、路線、倉儲、收件人、最後一哩路。

4. 為什麼要拆成兩段

因為「誰可以做」跟「做完怎麼送」是兩件不同的事。

如果把兩件事混在一起,系統會很難維護:

  • 權限邏輯會跟送達邏輯黏在一起
  • 找錯時不知道是派工錯,還是送達錯
  • 之後想加新 agent、新 channel、新 plugin 會很痛苦

OpenClaw 這裡拆得很乾淨,所以後面比較好擴充。

設計取捨

好處

  • 政策與執行分離,邏輯清楚
  • 出錯時能分辨是「不能派」還是「送不到」
  • 新增 agent 或 channel 時比較好接
  • 設定檔不會變成唯一真相,source code 才是規則核心

代價

  • 一開始讀起來比較碎
  • 你不能只看 config 就下結論
  • 必須同時理解 policy、registry、routing、session key
  • 文章也不能偷懶只貼設定,不然會看不出真正的行為

這就是我這次想修正的地方。

只看設定會很像「有這些角色」,但看 source code 才會知道「怎麼真的派出去」。

今天的結論

  • OpenClaw 的任務分派,核心不是設定,而是 source code 的 policy 與 routing
  • allowAgents 會先被 normalize,再跟實際 registry 做交集
  • sessions_spawn 會真的檢查 target 是否可被允許,不合法就直接回錯
  • resolveAgentDeliveryPlanresolveAgentDeliveryPlanWithSessionRoute 負責把結果送到對的 channel / account / session
  • 第 4 天真正要記住的是:任務不是「叫誰做」而已,而是「誰能接、怎麼接、做完怎麼送」

下一步

第 5 天我會接著看另一件很重要的事:工具怎麼變成 Agent 的手腳。

因為當分派已經搞清楚之後,下一個問題就是:

任務接到人手上以後,他到底是怎麼把事情做完的?

那就是下一篇要看的重點。


上一篇
第 3 天:它怎麼接到第一個任務,從入口開始看
系列文
30 天走進 OpenClaw:一個 AI Agent 的誕生、掙扎與進化4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言