Files
clawdbot/src/gateway/sessions-patch.ts
2026-01-13 06:48:26 +00:00

257 lines
8.0 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import {
resolveAllowedModelRef,
resolveConfiguredModelRef,
} from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatThinkingLevels,
formatXHighModelHint,
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeUsageDisplay,
supportsXHighThinking,
} 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 {
applyVerboseOverride,
parseVerboseOverride,
} from "../sessions/level-overrides.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import {
ErrorCodes,
type ErrorShape,
errorShape,
type SessionsPatchParams,
} from "./protocol/index.js";
function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
}
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 parsed = parseSessionLabel(raw);
if (!parsed.ok) return invalid(parsed.error);
for (const [key, entry] of Object.entries(store)) {
if (key === storeKey) continue;
if (entry?.label === parsed.label) {
return invalid(`label already in use: ${parsed.label}`);
}
}
next.label = parsed.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) {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const hintProvider =
existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel =
existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid(
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
);
}
if (normalized === "off") delete next.thinkingLevel;
else next.thinkingLevel = normalized;
}
}
if ("verboseLevel" in patch) {
const raw = patch.verboseLevel;
const parsed = parseVerboseOverride(raw);
if (!parsed.ok) return invalid(parsed.error);
applyVerboseOverride(next, parsed.value);
}
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")');
// Persist "off" explicitly so patches can override defaults.
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,
});
if (!params.loadGatewayModelCatalog) {
return {
ok: false,
error: errorShape(
ErrorCodes.UNAVAILABLE,
"model catalog unavailable",
),
};
}
const catalog = await params.loadGatewayModelCatalog();
const resolved = resolveAllowedModelRef({
cfg,
catalog,
raw: trimmed,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
if ("error" in resolved) {
return invalid(resolved.error);
}
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 (next.thinkingLevel === "xhigh") {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
if ("thinkingLevel" in patch) {
return invalid(
`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`,
);
}
next.thinkingLevel = "high";
}
}
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 };
}