mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-24 23:51:48 +00:00
When spawning a subagent, the requesterOrigin's threadId, to, and
accountId were not forwarded to the callGateway({method:'agent'}) params.
This meant the subagent's runContext had no currentThreadTs or
currentChannelId, so resolveTelegramAutoThreadId could not auto-inject
the forum topic thread ID when the subagent used the message tool.
Changes:
- sessions-spawn-tool: pass to, accountId, threadId from requesterOrigin
- run-context: populate currentChannelId from opts.to as fallback
Fixes subagent messages landing in General Topic instead of the correct
Telegram DM topic thread.
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { Type } from "@sinclair/typebox";
|
|
import crypto from "node:crypto";
|
|
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
|
import type { AnyAgentTool } from "./common.js";
|
|
import { formatThinkingLevels, normalizeThinkLevel } from "../../auto-reply/thinking.js";
|
|
import { loadConfig } from "../../config/config.js";
|
|
import { callGateway } from "../../gateway/call.js";
|
|
import {
|
|
isSubagentSessionKey,
|
|
normalizeAgentId,
|
|
parseAgentSessionKey,
|
|
} from "../../routing/session-key.js";
|
|
import { normalizeDeliveryContext } from "../../utils/delivery-context.js";
|
|
import { resolveAgentConfig } from "../agent-scope.js";
|
|
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
|
|
import { optionalStringEnum } from "../schema/typebox.js";
|
|
import { buildSubagentSystemPrompt } from "../subagent-announce.js";
|
|
import { registerSubagentRun } from "../subagent-registry.js";
|
|
import { jsonResult, readStringParam } from "./common.js";
|
|
import {
|
|
resolveDisplaySessionKey,
|
|
resolveInternalSessionKey,
|
|
resolveMainSessionAlias,
|
|
} from "./sessions-helpers.js";
|
|
|
|
const SessionsSpawnToolSchema = Type.Object({
|
|
task: Type.String(),
|
|
label: Type.Optional(Type.String()),
|
|
agentId: Type.Optional(Type.String()),
|
|
model: Type.Optional(Type.String()),
|
|
thinking: Type.Optional(Type.String()),
|
|
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
|
// Back-compat alias. Prefer runTimeoutSeconds.
|
|
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
|
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
|
});
|
|
|
|
function splitModelRef(ref?: string) {
|
|
if (!ref) {
|
|
return { provider: undefined, model: undefined };
|
|
}
|
|
const trimmed = ref.trim();
|
|
if (!trimmed) {
|
|
return { provider: undefined, model: undefined };
|
|
}
|
|
const [provider, model] = trimmed.split("/", 2);
|
|
if (model) {
|
|
return { provider, model };
|
|
}
|
|
return { provider: undefined, model: trimmed };
|
|
}
|
|
|
|
function normalizeModelSelection(value: unknown): string | undefined {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
if (!value || typeof value !== "object") {
|
|
return undefined;
|
|
}
|
|
const primary = (value as { primary?: unknown }).primary;
|
|
if (typeof primary === "string" && primary.trim()) {
|
|
return primary.trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function createSessionsSpawnTool(opts?: {
|
|
agentSessionKey?: string;
|
|
agentChannel?: GatewayMessageChannel;
|
|
agentAccountId?: string;
|
|
agentTo?: string;
|
|
agentThreadId?: string | number;
|
|
agentGroupId?: string | null;
|
|
agentGroupChannel?: string | null;
|
|
agentGroupSpace?: string | null;
|
|
sandboxed?: boolean;
|
|
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
|
|
requesterAgentIdOverride?: string;
|
|
}): AnyAgentTool {
|
|
return {
|
|
label: "Sessions",
|
|
name: "sessions_spawn",
|
|
description:
|
|
"Spawn a background sub-agent run in an isolated session and announce the result back to the requester chat.",
|
|
parameters: SessionsSpawnToolSchema,
|
|
execute: async (_toolCallId, args) => {
|
|
const params = args as Record<string, unknown>;
|
|
const task = readStringParam(params, "task", { required: true });
|
|
const label = typeof params.label === "string" ? params.label.trim() : "";
|
|
const requestedAgentId = readStringParam(params, "agentId");
|
|
const modelOverride = readStringParam(params, "model");
|
|
const thinkingOverrideRaw = readStringParam(params, "thinking");
|
|
const cleanup =
|
|
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
|
|
const requesterOrigin = normalizeDeliveryContext({
|
|
channel: opts?.agentChannel,
|
|
accountId: opts?.agentAccountId,
|
|
to: opts?.agentTo,
|
|
threadId: opts?.agentThreadId,
|
|
});
|
|
const runTimeoutSeconds = (() => {
|
|
const explicit =
|
|
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
|
|
? Math.max(0, Math.floor(params.runTimeoutSeconds))
|
|
: undefined;
|
|
if (explicit !== undefined) {
|
|
return explicit;
|
|
}
|
|
const legacy =
|
|
typeof params.timeoutSeconds === "number" && Number.isFinite(params.timeoutSeconds)
|
|
? Math.max(0, Math.floor(params.timeoutSeconds))
|
|
: undefined;
|
|
return legacy ?? 0;
|
|
})();
|
|
let modelWarning: string | undefined;
|
|
let modelApplied = false;
|
|
|
|
const cfg = loadConfig();
|
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
|
const requesterSessionKey = opts?.agentSessionKey;
|
|
if (typeof requesterSessionKey === "string" && isSubagentSessionKey(requesterSessionKey)) {
|
|
return jsonResult({
|
|
status: "forbidden",
|
|
error: "sessions_spawn is not allowed from sub-agent sessions",
|
|
});
|
|
}
|
|
const requesterInternalKey = requesterSessionKey
|
|
? resolveInternalSessionKey({
|
|
key: requesterSessionKey,
|
|
alias,
|
|
mainKey,
|
|
})
|
|
: alias;
|
|
const requesterDisplayKey = resolveDisplaySessionKey({
|
|
key: requesterInternalKey,
|
|
alias,
|
|
mainKey,
|
|
});
|
|
|
|
const requesterAgentId = normalizeAgentId(
|
|
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
|
|
);
|
|
const targetAgentId = requestedAgentId
|
|
? normalizeAgentId(requestedAgentId)
|
|
: requesterAgentId;
|
|
if (targetAgentId !== requesterAgentId) {
|
|
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
|
|
const allowAny = allowAgents.some((value) => value.trim() === "*");
|
|
const normalizedTargetId = targetAgentId.toLowerCase();
|
|
const allowSet = new Set(
|
|
allowAgents
|
|
.filter((value) => value.trim() && value.trim() !== "*")
|
|
.map((value) => normalizeAgentId(value).toLowerCase()),
|
|
);
|
|
if (!allowAny && !allowSet.has(normalizedTargetId)) {
|
|
const allowedText = allowAny
|
|
? "*"
|
|
: allowSet.size > 0
|
|
? Array.from(allowSet).join(", ")
|
|
: "none";
|
|
return jsonResult({
|
|
status: "forbidden",
|
|
error: `agentId is not allowed for sessions_spawn (allowed: ${allowedText})`,
|
|
});
|
|
}
|
|
}
|
|
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
|
|
const spawnedByKey = requesterInternalKey;
|
|
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
|
|
const resolvedModel =
|
|
normalizeModelSelection(modelOverride) ??
|
|
normalizeModelSelection(targetAgentConfig?.subagents?.model) ??
|
|
normalizeModelSelection(cfg.agents?.defaults?.subagents?.model);
|
|
|
|
const resolvedThinkingDefaultRaw =
|
|
readStringParam(targetAgentConfig?.subagents ?? {}, "thinking") ??
|
|
readStringParam(cfg.agents?.defaults?.subagents ?? {}, "thinking");
|
|
|
|
let thinkingOverride: string | undefined;
|
|
const thinkingCandidateRaw = thinkingOverrideRaw || resolvedThinkingDefaultRaw;
|
|
if (thinkingCandidateRaw) {
|
|
const normalized = normalizeThinkLevel(thinkingCandidateRaw);
|
|
if (!normalized) {
|
|
const { provider, model } = splitModelRef(resolvedModel);
|
|
const hint = formatThinkingLevels(provider, model);
|
|
return jsonResult({
|
|
status: "error",
|
|
error: `Invalid thinking level "${thinkingCandidateRaw}". Use one of: ${hint}.`,
|
|
});
|
|
}
|
|
thinkingOverride = normalized;
|
|
}
|
|
if (resolvedModel) {
|
|
try {
|
|
await callGateway({
|
|
method: "sessions.patch",
|
|
params: { key: childSessionKey, model: resolvedModel },
|
|
timeoutMs: 10_000,
|
|
});
|
|
modelApplied = true;
|
|
} catch (err) {
|
|
const messageText =
|
|
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
|
const recoverable =
|
|
messageText.includes("invalid model") || messageText.includes("model not allowed");
|
|
if (!recoverable) {
|
|
return jsonResult({
|
|
status: "error",
|
|
error: messageText,
|
|
childSessionKey,
|
|
});
|
|
}
|
|
modelWarning = messageText;
|
|
}
|
|
}
|
|
const childSystemPrompt = buildSubagentSystemPrompt({
|
|
requesterSessionKey,
|
|
requesterOrigin,
|
|
childSessionKey,
|
|
label: label || undefined,
|
|
task,
|
|
});
|
|
|
|
const childIdem = crypto.randomUUID();
|
|
let childRunId: string = childIdem;
|
|
try {
|
|
const response = await callGateway<{ runId: string }>({
|
|
method: "agent",
|
|
params: {
|
|
message: task,
|
|
sessionKey: childSessionKey,
|
|
channel: requesterOrigin?.channel,
|
|
to: requesterOrigin?.to ?? undefined,
|
|
accountId: requesterOrigin?.accountId ?? undefined,
|
|
threadId:
|
|
requesterOrigin?.threadId != null ? String(requesterOrigin.threadId) : undefined,
|
|
idempotencyKey: childIdem,
|
|
deliver: false,
|
|
lane: AGENT_LANE_SUBAGENT,
|
|
extraSystemPrompt: childSystemPrompt,
|
|
thinking: thinkingOverride,
|
|
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
|
|
label: label || undefined,
|
|
spawnedBy: spawnedByKey,
|
|
groupId: opts?.agentGroupId ?? undefined,
|
|
groupChannel: opts?.agentGroupChannel ?? undefined,
|
|
groupSpace: opts?.agentGroupSpace ?? undefined,
|
|
},
|
|
timeoutMs: 10_000,
|
|
});
|
|
if (typeof response?.runId === "string" && response.runId) {
|
|
childRunId = response.runId;
|
|
}
|
|
} catch (err) {
|
|
const messageText =
|
|
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
|
|
return jsonResult({
|
|
status: "error",
|
|
error: messageText,
|
|
childSessionKey,
|
|
runId: childRunId,
|
|
});
|
|
}
|
|
|
|
registerSubagentRun({
|
|
runId: childRunId,
|
|
childSessionKey,
|
|
requesterSessionKey: requesterInternalKey,
|
|
requesterOrigin,
|
|
requesterDisplayKey,
|
|
task,
|
|
cleanup,
|
|
label: label || undefined,
|
|
runTimeoutSeconds,
|
|
});
|
|
|
|
return jsonResult({
|
|
status: "accepted",
|
|
childSessionKey,
|
|
runId: childRunId,
|
|
modelApplied: resolvedModel ? modelApplied : undefined,
|
|
warning: modelWarning,
|
|
});
|
|
},
|
|
};
|
|
}
|