feat(sessions): label lookup tightening (#570) (thanks @azade-c)
This commit is contained in:
@@ -671,6 +671,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let label: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -679,6 +680,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
label: String?,
|
||||
spawnedby: String?,
|
||||
agentid: String?
|
||||
) {
|
||||
@@ -686,6 +688,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.label = label
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
}
|
||||
@@ -694,6 +697,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case label
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const SessionsSendToolSchema = Type.Union([
|
||||
),
|
||||
Type.Object(
|
||||
{
|
||||
label: Type.String(),
|
||||
label: Type.String({ minLength: 1, maxLength: 64 }),
|
||||
message: Type.String(),
|
||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
},
|
||||
@@ -81,7 +81,7 @@ export function createSessionsSendTool(opts?: {
|
||||
!isSubagentSessionKey(requesterInternalKey);
|
||||
|
||||
const sessionKeyParam = readStringParam(params, "sessionKey");
|
||||
const labelParam = readStringParam(params, "label");
|
||||
const labelParam = readStringParam(params, "label")?.trim() || undefined;
|
||||
if (sessionKeyParam && labelParam) {
|
||||
return jsonResult({
|
||||
runId: crypto.randomUUID(),
|
||||
@@ -99,32 +99,21 @@ export function createSessionsSendTool(opts?: {
|
||||
return Array.isArray(result?.sessions) ? result.sessions : [];
|
||||
};
|
||||
|
||||
const activeMinutes = 24 * 60;
|
||||
const visibleSessions = restrictToSpawned
|
||||
? await listSessions({
|
||||
activeMinutes,
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
limit: 500,
|
||||
spawnedBy: requesterInternalKey,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let sessionKey = sessionKeyParam;
|
||||
if (!sessionKey && labelParam) {
|
||||
const sessions =
|
||||
visibleSessions ??
|
||||
(await listSessions({
|
||||
activeMinutes,
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
limit: 500,
|
||||
}));
|
||||
const matches = sessions.filter((entry) => {
|
||||
const label =
|
||||
typeof entry?.label === "string" ? entry.label : undefined;
|
||||
return label === labelParam;
|
||||
});
|
||||
const agentIdForLookup = requesterInternalKey
|
||||
? normalizeAgentId(
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
)
|
||||
: undefined;
|
||||
const listParams: Record<string, unknown> = {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
label: labelParam,
|
||||
};
|
||||
if (restrictToSpawned) listParams.spawnedBy = requesterInternalKey;
|
||||
if (agentIdForLookup) listParams.agentId = agentIdForLookup;
|
||||
const matches = await listSessions(listParams);
|
||||
if (matches.length === 0) {
|
||||
if (restrictToSpawned) {
|
||||
return jsonResult({
|
||||
@@ -176,7 +165,18 @@ export function createSessionsSendTool(opts?: {
|
||||
});
|
||||
|
||||
if (restrictToSpawned) {
|
||||
const sessions = visibleSessions ?? [];
|
||||
const agentIdForLookup = requesterInternalKey
|
||||
? normalizeAgentId(
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||
)
|
||||
: undefined;
|
||||
const sessions = await listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
limit: 500,
|
||||
spawnedBy: requesterInternalKey,
|
||||
...(agentIdForLookup ? { agentId: agentIdForLookup } : {}),
|
||||
});
|
||||
const ok = sessions.some((entry) => entry?.key === resolvedKey);
|
||||
if (!ok) {
|
||||
return jsonResult({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
||||
|
||||
const NonEmptyString = Type.String({ minLength: 1 });
|
||||
const SessionLabelString = Type.String({ minLength: 1, maxLength: 64 });
|
||||
|
||||
export const PresenceEntrySchema = Type.Object(
|
||||
{
|
||||
@@ -225,7 +226,7 @@ export const AgentParamsSchema = Type.Object(
|
||||
lane: Type.Optional(Type.String()),
|
||||
extraSystemPrompt: Type.Optional(Type.String()),
|
||||
idempotencyKey: NonEmptyString,
|
||||
label: Type.Optional(Type.String()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
@@ -315,6 +316,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
@@ -324,7 +326,7 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
export const SessionsPatchParamsSchema = Type.Object(
|
||||
{
|
||||
key: NonEmptyString,
|
||||
label: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
resolveThinkingDefault,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
isEmbeddedPiRunActive,
|
||||
@@ -17,13 +9,6 @@ import {
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
|
||||
import {
|
||||
normalizeElevatedLevel,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import type { HealthSummary } from "../commands/health.js";
|
||||
@@ -49,9 +34,7 @@ import {
|
||||
setVoiceWakeTriggers,
|
||||
} from "../infra/voicewake.js";
|
||||
import { clearCommandLane } from "../process/command-queue.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -93,6 +76,7 @@ import {
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
} from "./session-utils.js";
|
||||
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
||||
import { formatForLog } from "./ws-log.js";
|
||||
|
||||
export type BridgeHandlersContext = {
|
||||
@@ -341,272 +325,29 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
const now = Date.now();
|
||||
|
||||
const existing = store[key];
|
||||
const next: SessionEntry = existing
|
||||
? {
|
||||
...existing,
|
||||
updatedAt: Math.max(existing.updatedAt ?? 0, now),
|
||||
}
|
||||
: { sessionId: randomUUID(), updatedAt: now };
|
||||
|
||||
if ("spawnedBy" in p) {
|
||||
const raw = p.spawnedBy;
|
||||
if (raw === null) {
|
||||
if (existing?.spawnedBy) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "spawnedBy cannot be cleared once set",
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid spawnedBy: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!isSubagentSessionKey(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message:
|
||||
"spawnedBy is only supported for subagent:* sessions",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "spawnedBy cannot be changed once set",
|
||||
},
|
||||
};
|
||||
}
|
||||
next.spawnedBy = trimmed;
|
||||
}
|
||||
const applied = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: key,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!applied.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: applied.error.code,
|
||||
message: applied.error.message,
|
||||
details: applied.error.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if ("label" in p) {
|
||||
const raw = p.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid label: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
next.label = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in p) {
|
||||
const raw = p.thinkingLevel;
|
||||
if (raw === null) {
|
||||
delete next.thinkingLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid thinkingLevel: ${String(raw)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
next.thinkingLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("verboseLevel" in p) {
|
||||
const raw = p.verboseLevel;
|
||||
if (raw === null) {
|
||||
delete next.verboseLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeVerboseLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid verboseLevel: ${String(raw)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
next.verboseLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoningLevel" in p) {
|
||||
const raw = p.reasoningLevel;
|
||||
if (raw === null) {
|
||||
delete next.reasoningLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeReasoningLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid reasoningLevel: ${String(raw)} (use on|off|stream)`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (normalized === "off") delete next.reasoningLevel;
|
||||
else next.reasoningLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("elevatedLevel" in p) {
|
||||
const raw = p.elevatedLevel;
|
||||
if (raw === null) {
|
||||
delete next.elevatedLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeElevatedLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid elevatedLevel: ${String(raw)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
next.elevatedLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid model: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid model: ${trimmed}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const catalog = await ctx.loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `model not allowed: ${key}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in p) {
|
||||
const raw = p.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: 'invalid sendPolicy (use "allow"|"deny")',
|
||||
},
|
||||
};
|
||||
}
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
delete next.groupActivation;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeGroupActivation(String(raw));
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid groupActivation: ${String(raw)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
next.groupActivation = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
store[key] = next;
|
||||
await saveSessionStore(storePath, store);
|
||||
const payload: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
key,
|
||||
entry: next,
|
||||
entry: applied.entry,
|
||||
};
|
||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||
}
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
isEmbeddedPiRunActive,
|
||||
resolveEmbeddedSessionLane,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||
import {
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeUsageDisplay,
|
||||
normalizeVerboseLevel,
|
||||
} from "../../auto-reply/thinking.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
@@ -30,8 +15,6 @@ import {
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { clearCommandLane } from "../../process/command-queue.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import { normalizeSendPolicy } from "../../sessions/send-policy.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
@@ -50,6 +33,7 @@ import {
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
} from "../session-utils.js";
|
||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
@@ -103,7 +87,6 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const storePath = target.storePath;
|
||||
const store = loadSessionStore(storePath);
|
||||
const now = Date.now();
|
||||
|
||||
const primaryKey = target.storeKeys[0] ?? key;
|
||||
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
|
||||
@@ -111,285 +94,23 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
const existing = store[primaryKey];
|
||||
const next: SessionEntry = existing
|
||||
? {
|
||||
...existing,
|
||||
updatedAt: Math.max(existing.updatedAt ?? 0, now),
|
||||
}
|
||||
: { sessionId: randomUUID(), updatedAt: now };
|
||||
|
||||
if ("spawnedBy" in p) {
|
||||
const raw = p.spawnedBy;
|
||||
if (raw === null) {
|
||||
if (existing?.spawnedBy) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"spawnedBy cannot be cleared once set",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid spawnedBy: empty"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isSubagentSessionKey(primaryKey)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"spawnedBy is only supported for subagent:* sessions",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"spawnedBy cannot be changed once set",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.spawnedBy = trimmed;
|
||||
}
|
||||
const applied = await applySessionsPatchToStore({
|
||||
cfg,
|
||||
store,
|
||||
storeKey: primaryKey,
|
||||
patch: p,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
});
|
||||
if (!applied.ok) {
|
||||
respond(false, undefined, applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if ("label" in p) {
|
||||
const raw = p.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid label: empty"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.label = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in p) {
|
||||
const raw = p.thinkingLevel;
|
||||
if (raw === null) {
|
||||
delete next.thinkingLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"invalid thinkingLevel (use off|minimal|low|medium|high)",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (normalized === "off") delete next.thinkingLevel;
|
||||
else next.thinkingLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("verboseLevel" in p) {
|
||||
const raw = p.verboseLevel;
|
||||
if (raw === null) {
|
||||
delete next.verboseLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeVerboseLevel(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid verboseLevel (use "on"|"off")',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (normalized === "off") delete next.verboseLevel;
|
||||
else next.verboseLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoningLevel" in p) {
|
||||
const raw = p.reasoningLevel;
|
||||
if (raw === null) {
|
||||
delete next.reasoningLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeReasoningLevel(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid reasoningLevel (use "on"|"off"|"stream")',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (normalized === "off") delete next.reasoningLevel;
|
||||
else next.reasoningLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("responseUsage" in p) {
|
||||
const raw = p.responseUsage;
|
||||
if (raw === null) {
|
||||
delete next.responseUsage;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeUsageDisplay(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid responseUsage (use "on"|"off")',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (normalized === "off") delete next.responseUsage;
|
||||
else next.responseUsage = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid model: empty"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `invalid model: ${trimmed}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const catalog = await context.loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `model not allowed: ${key}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in p) {
|
||||
const raw = p.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid sendPolicy (use "allow"|"deny")',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
delete next.groupActivation;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeGroupActivation(String(raw));
|
||||
if (!normalized) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
'invalid groupActivation (use "mention"|"always")',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next.groupActivation = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
store[primaryKey] = next;
|
||||
await saveSessionStore(storePath, store);
|
||||
const result: SessionsPatchResult = {
|
||||
ok: true,
|
||||
path: storePath,
|
||||
key: target.canonicalKey,
|
||||
entry: next,
|
||||
entry: applied.entry,
|
||||
};
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
|
||||
@@ -158,6 +158,12 @@ describe("gateway server sessions", () => {
|
||||
expect(labelPatched.ok).toBe(true);
|
||||
expect(labelPatched.payload?.entry.label).toBe("Briefing");
|
||||
|
||||
const labelPatchedDuplicate = await rpcReq(ws, "sessions.patch", {
|
||||
key: "agent:main:discord:group:dev",
|
||||
label: "Briefing",
|
||||
});
|
||||
expect(labelPatchedDuplicate.ok).toBe(false);
|
||||
|
||||
const list2 = await rpcReq<{
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
@@ -179,6 +185,18 @@ describe("gateway server sessions", () => {
|
||||
);
|
||||
expect(subagent?.label).toBe("Briefing");
|
||||
|
||||
const listByLabel = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
label: "Briefing",
|
||||
});
|
||||
expect(listByLabel.ok).toBe(true);
|
||||
expect(listByLabel.payload?.sessions.map((s) => s.key)).toEqual([
|
||||
"agent:main:subagent:one",
|
||||
]);
|
||||
|
||||
const spawnedOnly = await rpcReq<{
|
||||
sessions: Array<{ key: string }>;
|
||||
}>(ws, "sessions.list", {
|
||||
|
||||
@@ -435,6 +435,7 @@ export function listSessionsFromStore(params: {
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||
const agentId =
|
||||
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||
const activeMinutes =
|
||||
@@ -460,6 +461,10 @@ export function listSessionsFromStore(params: {
|
||||
if (key === "unknown" || key === "global") return false;
|
||||
return entry?.spawnedBy === spawnedBy;
|
||||
})
|
||||
.filter(([, entry]) => {
|
||||
if (!label) return true;
|
||||
return entry?.label === label;
|
||||
})
|
||||
.map(([key, entry]) => {
|
||||
const updatedAt = entry?.updatedAt ?? null;
|
||||
const input = entry?.inputTokens ?? 0;
|
||||
|
||||
254
src/gateway/sessions-patch.ts
Normal file
254
src/gateway/sessions-patch.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
|
||||
import {
|
||||
normalizeElevatedLevel,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
normalizeUsageDisplay,
|
||||
normalizeVerboseLevel,
|
||||
} from "../auto-reply/thinking.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
errorShape,
|
||||
type SessionsPatchParams,
|
||||
} from "./protocol/index.js";
|
||||
|
||||
export const SESSION_LABEL_MAX_LENGTH = 64;
|
||||
|
||||
function invalid(message: string): { ok: false; error: ErrorShape } {
|
||||
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
||||
}
|
||||
|
||||
function normalizeLabel(
|
||||
raw: unknown,
|
||||
): { ok: true; label: string } | ReturnType<typeof invalid> {
|
||||
const trimmed = 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: {
|
||||
cfg: ClawdbotConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
storeKey: string;
|
||||
patch: SessionsPatchParams;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
}): Promise<
|
||||
{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }
|
||||
> {
|
||||
const { cfg, store, storeKey, patch } = params;
|
||||
const now = Date.now();
|
||||
|
||||
const existing = store[storeKey];
|
||||
const next: SessionEntry = existing
|
||||
? {
|
||||
...existing,
|
||||
updatedAt: Math.max(existing.updatedAt ?? 0, now),
|
||||
}
|
||||
: { sessionId: randomUUID(), updatedAt: now };
|
||||
|
||||
if ("spawnedBy" in patch) {
|
||||
const raw = patch.spawnedBy;
|
||||
if (raw === null) {
|
||||
if (existing?.spawnedBy)
|
||||
return invalid("spawnedBy cannot be cleared once set");
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid spawnedBy: empty");
|
||||
if (!isSubagentSessionKey(storeKey)) {
|
||||
return invalid("spawnedBy is only supported for subagent:* sessions");
|
||||
}
|
||||
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
|
||||
return invalid("spawnedBy cannot be changed once set");
|
||||
}
|
||||
next.spawnedBy = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if ("label" in patch) {
|
||||
const raw = patch.label;
|
||||
if (raw === null) {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeLabel(raw);
|
||||
if (!normalized.ok) return normalized;
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key === storeKey) continue;
|
||||
if (entry?.label === normalized.label) {
|
||||
return invalid(`label already in use: ${normalized.label}`);
|
||||
}
|
||||
}
|
||||
next.label = normalized.label;
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in patch) {
|
||||
const raw = patch.thinkingLevel;
|
||||
if (raw === null) {
|
||||
delete next.thinkingLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeThinkLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid(
|
||||
"invalid thinkingLevel (use off|minimal|low|medium|high)",
|
||||
);
|
||||
}
|
||||
if (normalized === "off") delete next.thinkingLevel;
|
||||
else next.thinkingLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("verboseLevel" in patch) {
|
||||
const raw = patch.verboseLevel;
|
||||
if (raw === null) {
|
||||
delete next.verboseLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeVerboseLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")');
|
||||
if (normalized === "off") delete next.verboseLevel;
|
||||
else next.verboseLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoningLevel" in patch) {
|
||||
const raw = patch.reasoningLevel;
|
||||
if (raw === null) {
|
||||
delete next.reasoningLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeReasoningLevel(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid('invalid reasoningLevel (use "on"|"off"|"stream")');
|
||||
}
|
||||
if (normalized === "off") delete next.reasoningLevel;
|
||||
else next.reasoningLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("responseUsage" in patch) {
|
||||
const raw = patch.responseUsage;
|
||||
if (raw === null) {
|
||||
delete next.responseUsage;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeUsageDisplay(String(raw));
|
||||
if (!normalized) return invalid('invalid responseUsage (use "on"|"off")');
|
||||
if (normalized === "off") delete next.responseUsage;
|
||||
else next.responseUsage = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("elevatedLevel" in patch) {
|
||||
const raw = patch.elevatedLevel;
|
||||
if (raw === null) {
|
||||
delete next.elevatedLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeElevatedLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")');
|
||||
if (normalized === "off") delete next.elevatedLevel;
|
||||
else next.elevatedLevel = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in patch) {
|
||||
const raw = patch.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid model: empty");
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return invalid(`invalid model: ${trimmed}`);
|
||||
|
||||
if (!params.loadGatewayModelCatalog) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"model catalog unavailable",
|
||||
),
|
||||
};
|
||||
}
|
||||
const catalog = await params.loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
return invalid(`model not allowed: ${key}`);
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("sendPolicy" in patch) {
|
||||
const raw = patch.sendPolicy;
|
||||
if (raw === null) {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized)
|
||||
return invalid('invalid sendPolicy (use "allow"|"deny")');
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in patch) {
|
||||
const raw = patch.groupActivation;
|
||||
if (raw === null) {
|
||||
delete next.groupActivation;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeGroupActivation(String(raw));
|
||||
if (!normalized) {
|
||||
return invalid('invalid groupActivation (use "mention"|"always")');
|
||||
}
|
||||
next.groupActivation = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
store[storeKey] = next;
|
||||
return { ok: true, entry: next };
|
||||
}
|
||||
Reference in New Issue
Block a user