mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
refactor(sessions): add sessions.resolve + label helper (#570)
This commit is contained in:
@@ -703,6 +703,39 @@ public struct SessionsListParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
|
public let key: String?
|
||||||
|
public let label: String?
|
||||||
|
public let agentid: String?
|
||||||
|
public let spawnedby: String?
|
||||||
|
public let includeglobal: Bool?
|
||||||
|
public let includeunknown: Bool?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
key: String?,
|
||||||
|
label: String?,
|
||||||
|
agentid: String?,
|
||||||
|
spawnedby: String?,
|
||||||
|
includeglobal: Bool?,
|
||||||
|
includeunknown: Bool?
|
||||||
|
) {
|
||||||
|
self.key = key
|
||||||
|
self.label = label
|
||||||
|
self.agentid = agentid
|
||||||
|
self.spawnedby = spawnedby
|
||||||
|
self.includeglobal = includeglobal
|
||||||
|
self.includeunknown = includeunknown
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case key
|
||||||
|
case label
|
||||||
|
case agentid = "agentId"
|
||||||
|
case spawnedby = "spawnedBy"
|
||||||
|
case includeglobal = "includeGlobal"
|
||||||
|
case includeunknown = "includeUnknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct SessionsPatchParams: Codable, Sendable {
|
public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let label: AnyCodable?
|
public let label: AnyCodable?
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeAgentId,
|
normalizeAgentId,
|
||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
|
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||||
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { jsonResult, readStringParam } from "./common.js";
|
import { jsonResult, readStringParam } from "./common.js";
|
||||||
@@ -40,7 +41,8 @@ const SessionsSendToolSchema = Type.Union([
|
|||||||
),
|
),
|
||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
label: Type.String({ minLength: 1, maxLength: 64 }),
|
label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
|
||||||
|
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
||||||
message: Type.String(),
|
message: Type.String(),
|
||||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
},
|
},
|
||||||
@@ -80,8 +82,28 @@ export function createSessionsSendTool(opts?: {
|
|||||||
requesterInternalKey &&
|
requesterInternalKey &&
|
||||||
!isSubagentSessionKey(requesterInternalKey);
|
!isSubagentSessionKey(requesterInternalKey);
|
||||||
|
|
||||||
|
const routingA2A = cfg.tools?.agentToAgent;
|
||||||
|
const a2aEnabled = routingA2A?.enabled === true;
|
||||||
|
const allowPatterns = Array.isArray(routingA2A?.allow)
|
||||||
|
? routingA2A.allow
|
||||||
|
: [];
|
||||||
|
const matchesAllow = (agentId: string) => {
|
||||||
|
if (allowPatterns.length === 0) return true;
|
||||||
|
return allowPatterns.some((pattern) => {
|
||||||
|
const raw = String(pattern ?? "").trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (raw === "*") return true;
|
||||||
|
if (!raw.includes("*")) return raw === agentId;
|
||||||
|
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
|
||||||
|
return re.test(agentId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sessionKeyParam = readStringParam(params, "sessionKey");
|
const sessionKeyParam = readStringParam(params, "sessionKey");
|
||||||
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
||||||
|
const labelAgentIdParam =
|
||||||
|
readStringParam(params, "agentId")?.trim() || undefined;
|
||||||
if (sessionKeyParam && labelParam) {
|
if (sessionKeyParam && labelParam) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId: crypto.randomUUID(),
|
runId: crypto.randomUUID(),
|
||||||
@@ -101,20 +123,86 @@ export function createSessionsSendTool(opts?: {
|
|||||||
|
|
||||||
let sessionKey = sessionKeyParam;
|
let sessionKey = sessionKeyParam;
|
||||||
if (!sessionKey && labelParam) {
|
if (!sessionKey && labelParam) {
|
||||||
const agentIdForLookup = requesterInternalKey
|
const requesterAgentId = requesterInternalKey
|
||||||
? normalizeAgentId(
|
? normalizeAgentId(
|
||||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const listParams: Record<string, unknown> = {
|
const requestedAgentId = labelAgentIdParam
|
||||||
includeGlobal: false,
|
? normalizeAgentId(labelAgentIdParam)
|
||||||
includeUnknown: false,
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
restrictToSpawned &&
|
||||||
|
requestedAgentId &&
|
||||||
|
requesterAgentId &&
|
||||||
|
requestedAgentId !== requesterAgentId
|
||||||
|
) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Sandboxed sessions_send label lookup is limited to this agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
requesterAgentId &&
|
||||||
|
requestedAgentId &&
|
||||||
|
requestedAgentId !== requesterAgentId
|
||||||
|
) {
|
||||||
|
if (!a2aEnabled) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!matchesAllow(requesterAgentId) ||
|
||||||
|
!matchesAllow(requestedAgentId)
|
||||||
|
) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error:
|
||||||
|
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveParams: Record<string, unknown> = {
|
||||||
label: labelParam,
|
label: labelParam,
|
||||||
|
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
|
||||||
|
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
|
||||||
};
|
};
|
||||||
if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey;
|
let resolvedKey = "";
|
||||||
if (agentIdForLookup) listParams.agentId = agentIdForLookup;
|
try {
|
||||||
const matches = await listSessions(listParams);
|
const resolved = (await callGateway({
|
||||||
if (matches.length === 0) {
|
method: "sessions.resolve",
|
||||||
|
params: resolveParams,
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
})) as { key?: unknown };
|
||||||
|
resolvedKey =
|
||||||
|
typeof resolved?.key === "string" ? resolved.key.trim() : "";
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (restrictToSpawned) {
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "forbidden",
|
||||||
|
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResult({
|
||||||
|
runId: crypto.randomUUID(),
|
||||||
|
status: "error",
|
||||||
|
error: msg || `No session found with label: ${labelParam}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedKey) {
|
||||||
if (restrictToSpawned) {
|
if (restrictToSpawned) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
runId: crypto.randomUUID(),
|
runId: crypto.randomUUID(),
|
||||||
@@ -128,26 +216,7 @@ export function createSessionsSendTool(opts?: {
|
|||||||
error: `No session found with label: ${labelParam}`,
|
error: `No session found with label: ${labelParam}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (matches.length > 1) {
|
sessionKey = resolvedKey;
|
||||||
const keys = matches
|
|
||||||
.map((entry) => (typeof entry?.key === "string" ? entry.key : ""))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
return jsonResult({
|
|
||||||
runId: crypto.randomUUID(),
|
|
||||||
status: "error",
|
|
||||||
error: `Multiple sessions found with label: ${labelParam}${keys ? ` (${keys})` : ""}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const key = matches[0]?.key;
|
|
||||||
if (typeof key !== "string" || !key.trim()) {
|
|
||||||
return jsonResult({
|
|
||||||
runId: crypto.randomUUID(),
|
|
||||||
status: "error",
|
|
||||||
error: `Invalid session entry for label: ${labelParam}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sessionKey = key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
@@ -165,17 +234,11 @@ export function createSessionsSendTool(opts?: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (restrictToSpawned) {
|
if (restrictToSpawned) {
|
||||||
const agentIdForLookup = requesterInternalKey
|
|
||||||
? normalizeAgentId(
|
|
||||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const sessions = await listSessions({
|
const sessions = await listSessions({
|
||||||
includeGlobal: false,
|
includeGlobal: false,
|
||||||
includeUnknown: false,
|
includeUnknown: false,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
spawnedBy: requesterInternalKey,
|
spawnedBy: requesterInternalKey,
|
||||||
...(agentIdForLookup ? { agentId: agentIdForLookup } : {}),
|
|
||||||
});
|
});
|
||||||
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -205,24 +268,6 @@ export function createSessionsSendTool(opts?: {
|
|||||||
alias,
|
alias,
|
||||||
mainKey,
|
mainKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const routingA2A = cfg.tools?.agentToAgent;
|
|
||||||
const a2aEnabled = routingA2A?.enabled === true;
|
|
||||||
const allowPatterns = Array.isArray(routingA2A?.allow)
|
|
||||||
? routingA2A.allow
|
|
||||||
: [];
|
|
||||||
const matchesAllow = (agentId: string) => {
|
|
||||||
if (allowPatterns.length === 0) return true;
|
|
||||||
return allowPatterns.some((pattern) => {
|
|
||||||
const raw = String(pattern ?? "").trim();
|
|
||||||
if (!raw) return false;
|
|
||||||
if (raw === "*") return true;
|
|
||||||
if (!raw.includes("*")) return raw === agentId;
|
|
||||||
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
|
|
||||||
return re.test(agentId);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const requesterAgentId = normalizeAgentId(
|
const requesterAgentId = normalizeAgentId(
|
||||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ import {
|
|||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
type SessionsResetParams,
|
type SessionsResetParams,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
|
type SessionsResolveParams,
|
||||||
|
SessionsResolveParamsSchema,
|
||||||
type ShutdownEvent,
|
type ShutdownEvent,
|
||||||
ShutdownEventSchema,
|
ShutdownEventSchema,
|
||||||
type SkillsInstallParams,
|
type SkillsInstallParams,
|
||||||
@@ -201,6 +203,9 @@ export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
|
|||||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(
|
export const validateSessionsListParams = ajv.compile<SessionsListParams>(
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||||
|
SessionsResolveParamsSchema,
|
||||||
|
);
|
||||||
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -417,6 +422,7 @@ export type {
|
|||||||
NodeListParams,
|
NodeListParams,
|
||||||
NodeInvokeParams,
|
NodeInvokeParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
|
SessionsResolveParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
SessionsResetParams,
|
SessionsResetParams,
|
||||||
SessionsDeleteParams,
|
SessionsDeleteParams,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||||
|
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
|
||||||
|
|
||||||
const NonEmptyString = Type.String({ minLength: 1 });
|
const NonEmptyString = Type.String({ minLength: 1 });
|
||||||
const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 });
|
const SessionLabelString = Type.String({
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: SESSION_LABEL_MAX_LENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
export const PresenceEntrySchema = Type.Object(
|
export const PresenceEntrySchema = Type.Object(
|
||||||
{
|
{
|
||||||
@@ -323,6 +327,18 @@ export const SessionsListParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
key: Type.Optional(NonEmptyString),
|
||||||
|
label: Type.Optional(SessionLabelString),
|
||||||
|
agentId: Type.Optional(NonEmptyString),
|
||||||
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const SessionsPatchParamsSchema = Type.Object(
|
export const SessionsPatchParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
@@ -938,6 +954,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
|
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
SessionsResetParams: SessionsResetParamsSchema,
|
SessionsResetParams: SessionsResetParamsSchema,
|
||||||
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||||
@@ -1014,6 +1031,7 @@ export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
|||||||
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
|
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { buildConfigSchema } from "../config/schema.js";
|
|||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
@@ -45,6 +44,7 @@ import {
|
|||||||
type SessionsListParams,
|
type SessionsListParams,
|
||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
type SessionsResetParams,
|
type SessionsResetParams,
|
||||||
|
type SessionsResolveParams,
|
||||||
validateChatAbortParams,
|
validateChatAbortParams,
|
||||||
validateChatHistoryParams,
|
validateChatHistoryParams,
|
||||||
validateChatSendParams,
|
validateChatSendParams,
|
||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
validateSessionsPatchParams,
|
validateSessionsPatchParams,
|
||||||
validateSessionsResetParams,
|
validateSessionsResetParams,
|
||||||
|
validateSessionsResolveParams,
|
||||||
validateTalkModeParams,
|
validateTalkModeParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
import type { ChatRunEntry } from "./server-chat.js";
|
import type { ChatRunEntry } from "./server-chat.js";
|
||||||
@@ -70,8 +71,10 @@ import {
|
|||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
|
loadCombinedSessionStoreForGateway,
|
||||||
loadSessionEntry,
|
loadSessionEntry,
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionModelRef,
|
resolveSessionModelRef,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
type SessionsPatchResult,
|
type SessionsPatchResult,
|
||||||
@@ -288,8 +291,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
}
|
}
|
||||||
const p = params as SessionsListParams;
|
const p = params as SessionsListParams;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const result = listSessionsFromStore({
|
const result = listSessionsFromStore({
|
||||||
cfg,
|
cfg,
|
||||||
storePath,
|
storePath,
|
||||||
@@ -298,6 +300,109 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
});
|
});
|
||||||
return { ok: true, payloadJSON: JSON.stringify(result) };
|
return { ok: true, payloadJSON: JSON.stringify(result) };
|
||||||
}
|
}
|
||||||
|
case "sessions.resolve": {
|
||||||
|
const params = parseParams();
|
||||||
|
if (!validateSessionsResolveParams(params)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = params as SessionsResolveParams;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
const key = typeof p.key === "string" ? p.key.trim() : "";
|
||||||
|
const label = typeof p.label === "string" ? p.label.trim() : "";
|
||||||
|
const hasKey = key.length > 0;
|
||||||
|
const hasLabel = label.length > 0;
|
||||||
|
if (hasKey && hasLabel) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "Provide either key or label (not both)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasKey && !hasLabel) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: "Either key or label is required",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const store = loadSessionStore(target.storePath);
|
||||||
|
const existingKey = target.storeKeys.find(
|
||||||
|
(candidate) => store[candidate],
|
||||||
|
);
|
||||||
|
if (!existingKey) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `No session found: ${key}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key: target.canonicalKey,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
|
const list = listSessionsFromStore({
|
||||||
|
cfg,
|
||||||
|
storePath,
|
||||||
|
store,
|
||||||
|
opts: {
|
||||||
|
includeGlobal: p.includeGlobal === true,
|
||||||
|
includeUnknown: p.includeUnknown === true,
|
||||||
|
label,
|
||||||
|
agentId: p.agentId,
|
||||||
|
spawnedBy: p.spawnedBy,
|
||||||
|
limit: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (list.sessions.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `No session found with label: ${label}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (list.sessions.length > 1) {
|
||||||
|
const keys = list.sessions.map((s) => s.key).join(", ");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCodes.INVALID_REQUEST,
|
||||||
|
message: `Multiple sessions found with label: ${label} (${keys})`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
key: list.sessions[0]?.key,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
case "sessions.patch": {
|
case "sessions.patch": {
|
||||||
const params = parseParams();
|
const params = parseParams();
|
||||||
if (!validateSessionsPatchParams(params)) {
|
if (!validateSessionsPatchParams(params)) {
|
||||||
@@ -323,12 +428,21 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const storePath = resolveStorePath(cfg.session?.store);
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const storePath = target.storePath;
|
||||||
const store = loadSessionStore(storePath);
|
const store = loadSessionStore(storePath);
|
||||||
|
const primaryKey = target.storeKeys[0] ?? key;
|
||||||
|
const existingKey = target.storeKeys.find(
|
||||||
|
(candidate) => store[candidate],
|
||||||
|
);
|
||||||
|
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
|
||||||
|
store[primaryKey] = store[existingKey];
|
||||||
|
delete store[existingKey];
|
||||||
|
}
|
||||||
const applied = await applySessionsPatchToStore({
|
const applied = await applySessionsPatchToStore({
|
||||||
cfg,
|
cfg,
|
||||||
store,
|
store,
|
||||||
storeKey: key,
|
storeKey: primaryKey,
|
||||||
patch: p,
|
patch: p,
|
||||||
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||||
});
|
});
|
||||||
@@ -346,7 +460,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
|||||||
const payload: SessionsPatchResult = {
|
const payload: SessionsPatchResult = {
|
||||||
ok: true,
|
ok: true,
|
||||||
path: storePath,
|
path: storePath,
|
||||||
key,
|
key: target.canonicalKey,
|
||||||
entry: applied.entry,
|
entry: applied.entry,
|
||||||
};
|
};
|
||||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
validateSessionsPatchParams,
|
validateSessionsPatchParams,
|
||||||
validateSessionsResetParams,
|
validateSessionsResetParams,
|
||||||
|
validateSessionsResolveParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import {
|
import {
|
||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
@@ -60,6 +61,122 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
},
|
},
|
||||||
|
"sessions.resolve": ({ params, respond }) => {
|
||||||
|
if (!validateSessionsResolveParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = params as import("../protocol/index.js").SessionsResolveParams;
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
const key = typeof p.key === "string" ? p.key.trim() : "";
|
||||||
|
const label = typeof p.label === "string" ? p.label.trim() : "";
|
||||||
|
const hasKey = key.length > 0;
|
||||||
|
const hasLabel = label.length > 0;
|
||||||
|
if (hasKey && hasLabel) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"Provide either key or label (not both)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasKey && !hasLabel) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
"Either key or label is required",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKey) {
|
||||||
|
if (!key) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const store = loadSessionStore(target.storePath);
|
||||||
|
const existingKey = target.storeKeys.find(
|
||||||
|
(candidate) => store[candidate],
|
||||||
|
);
|
||||||
|
if (!existingKey) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(true, { ok: true, key: target.canonicalKey }, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, "label required"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
||||||
|
const list = listSessionsFromStore({
|
||||||
|
cfg,
|
||||||
|
storePath,
|
||||||
|
store,
|
||||||
|
opts: {
|
||||||
|
includeGlobal: p.includeGlobal === true,
|
||||||
|
includeUnknown: p.includeUnknown === true,
|
||||||
|
label,
|
||||||
|
agentId: p.agentId,
|
||||||
|
spawnedBy: p.spawnedBy,
|
||||||
|
limit: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (list.sessions.length === 0) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`No session found with label: ${label}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (list.sessions.length > 1) {
|
||||||
|
const keys = list.sessions.map((s) => s.key).join(", ");
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`Multiple sessions found with label: ${label} (${keys})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(true, { ok: true, key: list.sessions[0]?.key }, undefined);
|
||||||
|
},
|
||||||
"sessions.patch": async ({ params, respond, context }) => {
|
"sessions.patch": async ({ params, respond, context }) => {
|
||||||
if (!validateSessionsPatchParams(params)) {
|
if (!validateSessionsPatchParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
@@ -642,6 +642,17 @@ describe("gateway server node/bridge", () => {
|
|||||||
expect(typeof payload.count).toBe("number");
|
expect(typeof payload.count).toBe("number");
|
||||||
expect(typeof payload.path).toBe("string");
|
expect(typeof payload.path).toBe("string");
|
||||||
|
|
||||||
|
const resolveRes = await bridgeCall?.onRequest?.("ios-node", {
|
||||||
|
id: "r2",
|
||||||
|
method: "sessions.resolve",
|
||||||
|
paramsJSON: JSON.stringify({ key: "main" }),
|
||||||
|
});
|
||||||
|
expect(resolveRes?.ok).toBe(true);
|
||||||
|
const resolvedPayload = JSON.parse(
|
||||||
|
String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"),
|
||||||
|
) as { key?: string };
|
||||||
|
expect(resolvedPayload.key).toBe("agent:main:main");
|
||||||
|
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ describe("gateway server sessions", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolvedByKey = await rpcReq<{ ok: true; key: string }>(
|
||||||
|
ws,
|
||||||
|
"sessions.resolve",
|
||||||
|
{ key: "main" },
|
||||||
|
);
|
||||||
|
expect(resolvedByKey.ok).toBe(true);
|
||||||
|
expect(resolvedByKey.payload?.key).toBe("agent:main:main");
|
||||||
|
|
||||||
const list1 = await rpcReq<{
|
const list1 = await rpcReq<{
|
||||||
path: string;
|
path: string;
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
@@ -197,6 +205,14 @@ describe("gateway server sessions", () => {
|
|||||||
"agent:main:subagent:one",
|
"agent:main:subagent:one",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const resolvedByLabel = await rpcReq<{ ok: true; key: string }>(
|
||||||
|
ws,
|
||||||
|
"sessions.resolve",
|
||||||
|
{ label: "Briefing", agentId: "main" },
|
||||||
|
);
|
||||||
|
expect(resolvedByLabel.ok).toBe(true);
|
||||||
|
expect(resolvedByLabel.payload?.key).toBe("agent:main:subagent:one");
|
||||||
|
|
||||||
const spawnedOnly = await rpcReq<{
|
const spawnedOnly = await rpcReq<{
|
||||||
sessions: Array<{ key: string }>;
|
sessions: Array<{ key: string }>;
|
||||||
}>(ws, "sessions.list", {
|
}>(ws, "sessions.list", {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||||
|
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
@@ -28,28 +29,10 @@ import {
|
|||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
} from "./protocol/index.js";
|
} from "./protocol/index.js";
|
||||||
|
|
||||||
export const SESSION_LABEL_MAX_LENGTH = 64;
|
|
||||||
|
|
||||||
function invalid(message: string): { ok: false; error: ErrorShape } {
|
function invalid(message: string): { ok: false; error: ErrorShape } {
|
||||||
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLabel(
|
|
||||||
raw: unknown,
|
|
||||||
): { ok: true; label: string } | ReturnType<typeof invalid> {
|
|
||||||
const trimmed =
|
|
||||||
typeof raw === "string"
|
|
||||||
? raw.trim()
|
|
||||||
: typeof raw === "number" || typeof raw === "boolean"
|
|
||||||
? String(raw).trim()
|
|
||||||
: "";
|
|
||||||
if (!trimmed) return invalid("invalid label: empty");
|
|
||||||
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
|
|
||||||
return invalid(`invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`);
|
|
||||||
}
|
|
||||||
return { ok: true, label: trimmed };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applySessionsPatchToStore(params: {
|
export async function applySessionsPatchToStore(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
store: Record<string, SessionEntry>;
|
store: Record<string, SessionEntry>;
|
||||||
@@ -93,15 +76,15 @@ export async function applySessionsPatchToStore(params: {
|
|||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
delete next.label;
|
delete next.label;
|
||||||
} else if (raw !== undefined) {
|
} else if (raw !== undefined) {
|
||||||
const normalized = normalizeLabel(raw);
|
const parsed = parseSessionLabel(raw);
|
||||||
if (!normalized.ok) return normalized;
|
if (!parsed.ok) return invalid(parsed.error);
|
||||||
for (const [key, entry] of Object.entries(store)) {
|
for (const [key, entry] of Object.entries(store)) {
|
||||||
if (key === storeKey) continue;
|
if (key === storeKey) continue;
|
||||||
if (entry?.label === normalized.label) {
|
if (entry?.label === parsed.label) {
|
||||||
return invalid(`label already in use: ${normalized.label}`);
|
return invalid(`label already in use: ${parsed.label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.label = normalized.label;
|
next.label = parsed.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
src/sessions/session-label.ts
Normal file
20
src/sessions/session-label.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export const SESSION_LABEL_MAX_LENGTH = 64;
|
||||||
|
|
||||||
|
export type ParsedSessionLabel =
|
||||||
|
| { ok: true; label: string }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export function parseSessionLabel(raw: unknown): ParsedSessionLabel {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return { ok: false, error: "invalid label: must be a string" };
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return { ok: false, error: "invalid label: empty" };
|
||||||
|
if (trimmed.length > SESSION_LABEL_MAX_LENGTH) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `invalid label: too long (max ${SESSION_LABEL_MAX_LENGTH})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, label: trimmed };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user