Skip to content

Commit 2d3f3de

Browse files
committed
fix(gateway): bootstrap agent sessions before send
1 parent 1d6e5f7 commit 2d3f3de

4 files changed

Lines changed: 215 additions & 6 deletions

File tree

src/agents/tool-description-presets.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export const SESSIONS_LIST_TOOL_DISPLAY_SUMMARY =
55
"List visible sessions with mailbox filters and optional previews.";
66
export const SESSIONS_HISTORY_TOOL_DISPLAY_SUMMARY =
77
"Read sanitized message history for a visible session.";
8-
export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY = "Send a message to another visible session.";
8+
export const SESSIONS_SEND_TOOL_DISPLAY_SUMMARY =
9+
"Send a message to another visible session or configured agent.";
910
export const SESSIONS_SPAWN_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent or ACP sessions.";
1011
export const SESSIONS_SPAWN_SUBAGENT_TOOL_DISPLAY_SUMMARY = "Spawn sub-agent sessions.";
1112
export const SESSION_STATUS_TOOL_DISPLAY_SUMMARY = "Show session status, usage, and model state.";
@@ -27,9 +28,9 @@ export function describeSessionsHistoryTool(): string {
2728

2829
export function describeSessionsSendTool(): string {
2930
return [
30-
"Send a message into another visible session by sessionKey or label.",
31+
"Send a message into another visible session by sessionKey or label, or to a configured agent by agentId.",
3132
"Thread-scoped chat sessions are rejected; target the parent channel session for inter-agent coordination.",
32-
"Use this to delegate follow-up work to an existing session; waits for the target run and returns the updated assistant reply when available.",
33+
"Missing configured agent main sessions are created before send; waits for the target run and returns the updated assistant reply when available.",
3334
].join(" ");
3435
}
3536

src/agents/tools/sessions-send-tool.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
isSubagentSessionKey,
1111
normalizeAgentId,
1212
resolveAgentIdFromSessionKey,
13+
toAgentStoreSessionKey,
1314
} from "../../routing/session-key.js";
1415
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
1516
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
@@ -18,6 +19,7 @@ import {
1819
type GatewayMessageChannel,
1920
INTERNAL_MESSAGE_CHANNEL,
2021
} from "../../utils/message-channel.js";
22+
import { listAgentIds } from "../agent-scope.js";
2123
import { resolveNestedAgentLaneForSession } from "../lanes.js";
2224
import {
2325
type AgentWaitResult,
@@ -53,6 +55,78 @@ const SessionsSendToolSchema = Type.Object({
5355
type GatewayCaller = typeof callGateway;
5456
const SESSIONS_SEND_REPLY_HISTORY_LIMIT = 50;
5557

58+
function resolveConfiguredAgentMainSessionKey(params: {
59+
cfg: OpenClawConfig;
60+
agentId: string;
61+
mainKey: string;
62+
}): string | undefined {
63+
const agentId = normalizeAgentId(params.agentId);
64+
if (!listAgentIds(params.cfg).includes(agentId)) {
65+
return undefined;
66+
}
67+
return toAgentStoreSessionKey({
68+
agentId,
69+
requestKey: "main",
70+
mainKey: params.mainKey,
71+
});
72+
}
73+
74+
function isConfiguredAgentMainSessionKey(params: {
75+
cfg: OpenClawConfig;
76+
sessionKey: string;
77+
mainKey: string;
78+
}): boolean {
79+
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
80+
return (
81+
params.sessionKey ===
82+
resolveConfiguredAgentMainSessionKey({
83+
cfg: params.cfg,
84+
agentId,
85+
mainKey: params.mainKey,
86+
})
87+
);
88+
}
89+
90+
async function ensureConfiguredAgentMainSession(params: {
91+
cfg: OpenClawConfig;
92+
callGateway: GatewayCaller;
93+
sessionKey: string;
94+
mainKey: string;
95+
}): Promise<{ ok: true } | { ok: false; error: string }> {
96+
if (
97+
!isConfiguredAgentMainSessionKey({
98+
cfg: params.cfg,
99+
sessionKey: params.sessionKey,
100+
mainKey: params.mainKey,
101+
})
102+
) {
103+
return { ok: true };
104+
}
105+
106+
try {
107+
await params.callGateway({
108+
method: "sessions.resolve",
109+
params: { key: params.sessionKey },
110+
timeoutMs: 10_000,
111+
});
112+
return { ok: true };
113+
} catch {
114+
try {
115+
await params.callGateway({
116+
method: "sessions.create",
117+
params: {
118+
key: params.sessionKey,
119+
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
120+
},
121+
timeoutMs: 10_000,
122+
});
123+
return { ok: true };
124+
} catch (err) {
125+
return { ok: false, error: formatErrorMessage(err) };
126+
}
127+
}
128+
}
129+
56130
type SessionsSendRouteEntry = Pick<SessionEntry, "acp" | "parentSessionKey" | "spawnedBy">;
57131

58132
function isRequesterParentOfNativeSubagentSession(params: {
@@ -145,6 +219,21 @@ export function createSessionsSendTool(opts?: {
145219
}
146220

147221
let sessionKey = sessionKeyParam;
222+
if (!sessionKey && !labelParam && labelAgentIdParam) {
223+
const agentMainKey = resolveConfiguredAgentMainSessionKey({
224+
cfg,
225+
agentId: labelAgentIdParam,
226+
mainKey,
227+
});
228+
if (!agentMainKey) {
229+
return jsonResult({
230+
runId: crypto.randomUUID(),
231+
status: "error",
232+
error: `agent not found: ${labelAgentIdParam}`,
233+
});
234+
}
235+
sessionKey = agentMainKey;
236+
}
148237
if (!sessionKey && labelParam) {
149238
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
150239
const requestedAgentId = labelAgentIdParam
@@ -294,6 +383,21 @@ export function createSessionsSendTool(opts?: {
294383
});
295384
}
296385

386+
const ensuredSession = await ensureConfiguredAgentMainSession({
387+
cfg,
388+
callGateway: gatewayCall,
389+
sessionKey: resolvedKey,
390+
mainKey,
391+
});
392+
if (!ensuredSession.ok) {
393+
return jsonResult({
394+
runId: crypto.randomUUID(),
395+
status: "error",
396+
error: ensuredSession.error,
397+
sessionKey: displayKey,
398+
});
399+
}
400+
297401
// Capture the pre-run assistant snapshot before starting the nested run.
298402
// Fast in-process test doubles and short-circuit agent paths can finish
299403
// before we reach the post-run read, which would otherwise make the new

src/gateway/server-methods/sessions.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,87 @@ function cloneCheckpointSessionEntry(params: {
398398
};
399399
}
400400

401+
function isAgentMainSessionKey(cfg: OpenClawConfig, sessionKey: string): boolean {
402+
const parsed = parseAgentSessionKey(sessionKey);
403+
if (!parsed) {
404+
return false;
405+
}
406+
return sessionKey === resolveAgentMainSessionKey({ cfg, agentId: parsed.agentId });
407+
}
408+
409+
async function createAgentMainSessionForSend(params: {
410+
req: GatewayRequestHandlerOptions["req"];
411+
canonicalKey: string;
412+
context: GatewayRequestContext;
413+
client: GatewayClient | null;
414+
isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"];
415+
}): Promise<
416+
| {
417+
ok: true;
418+
entry: SessionEntry;
419+
canonicalKey: string;
420+
storePath: string;
421+
}
422+
| { ok: false; error: ReturnType<typeof errorShape> }
423+
> {
424+
const agentId = parseAgentSessionKey(params.canonicalKey)?.agentId;
425+
if (!agentId) {
426+
return {
427+
ok: false,
428+
error: errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${params.canonicalKey}`),
429+
};
430+
}
431+
432+
let createResult:
433+
| { ok: boolean; payload?: { key?: string }; error?: ReturnType<typeof errorShape> }
434+
| undefined;
435+
await sessionsHandlers["sessions.create"]({
436+
req: params.req,
437+
params: {
438+
key: params.canonicalKey,
439+
agentId,
440+
},
441+
respond: (ok, payload, error) => {
442+
createResult = {
443+
ok,
444+
payload: payload && typeof payload === "object" ? (payload as { key?: string }) : undefined,
445+
error,
446+
};
447+
},
448+
context: params.context,
449+
client: params.client,
450+
isWebchatConnect: params.isWebchatConnect,
451+
});
452+
453+
if (!createResult) {
454+
return {
455+
ok: false,
456+
error: errorShape(ErrorCodes.UNAVAILABLE, "sessions.create did not respond"),
457+
};
458+
}
459+
if (!createResult.ok) {
460+
return {
461+
ok: false,
462+
error: createResult.error ?? errorShape(ErrorCodes.UNAVAILABLE, "failed to create session"),
463+
};
464+
}
465+
466+
const createdKey = normalizeOptionalString(createResult.payload?.key) ?? params.canonicalKey;
467+
const loaded = loadSessionEntry(createdKey);
468+
if (!loaded.entry?.sessionId) {
469+
return {
470+
ok: false,
471+
error: errorShape(ErrorCodes.UNAVAILABLE, `session not created: ${createdKey}`),
472+
};
473+
}
474+
return {
475+
ok: true,
476+
entry: loaded.entry,
477+
canonicalKey: loaded.canonicalKey,
478+
storePath: loaded.storePath,
479+
};
480+
}
481+
401482
function ensureSessionTranscriptFile(params: {
402483
sessionId: string;
403484
storePath: string;
@@ -581,7 +662,9 @@ async function handleSessionSend(params: {
581662
if (!key) {
582663
return;
583664
}
584-
const { cfg, entry, canonicalKey, storePath } = loadSessionEntry(key);
665+
const loaded = loadSessionEntry(key);
666+
const { cfg } = loaded;
667+
let { entry, canonicalKey, storePath } = loaded;
585668
// Reject sends/steers targeting sessions whose owning agent was deleted (#65524).
586669
const deletedAgentId = resolveDeletedAgentIdFromSessionKey(cfg, canonicalKey);
587670
if (deletedAgentId !== null) {
@@ -595,6 +678,22 @@ async function handleSessionSend(params: {
595678
);
596679
return;
597680
}
681+
if (!entry?.sessionId && !params.interruptIfActive && isAgentMainSessionKey(cfg, canonicalKey)) {
682+
const created = await createAgentMainSessionForSend({
683+
req: params.req,
684+
canonicalKey,
685+
context: params.context,
686+
client: params.client,
687+
isWebchatConnect: params.isWebchatConnect,
688+
});
689+
if (!created.ok) {
690+
params.respond(false, undefined, created.error);
691+
return;
692+
}
693+
entry = created.entry;
694+
canonicalKey = created.canonicalKey;
695+
storePath = created.storePath;
696+
}
598697
if (!entry?.sessionId) {
599698
params.respond(
600699
false,

src/gateway/sessions-patch.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,17 @@ export async function applySessionsPatchToStore(params: {
119119
};
120120

121121
const existing = store[storeKey];
122-
const next: SessionEntry = existing
122+
const next: SessionEntry = existing?.sessionId
123123
? {
124124
...existing,
125125
updatedAt: Math.max(existing.updatedAt ?? 0, now),
126126
}
127-
: { sessionId: randomUUID(), updatedAt: now };
127+
: {
128+
...(existing ?? {}),
129+
sessionId: randomUUID(),
130+
sessionFile: undefined,
131+
updatedAt: Math.max(existing?.updatedAt ?? 0, now),
132+
};
128133

129134
if ("spawnedBy" in patch) {
130135
const raw = patch.spawnedBy;

0 commit comments

Comments
 (0)