- Add `label` field to session entries and expose it in `sessions.list`
- Display label column in the web UI sessions table
- Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey
- `sessions.patch`: Accept and store `label` field
- `sessions.list`: Return `label` in session entries
- `sessions_spawn`: Pass label through to registry and announce flow
- `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided
- `agent` method: Accept `label` and `spawnedBy` params (stored in session entry)
- Add `label` column to sessions table in web UI
- Changed session store writes to merge with existing entry (`{ ...existing, ...new }`)
to preserve fields like `label` that might be set separately
We attempted to implement label persistence "properly" by passing the label
through the `agent` call and storing it during session initialization. However,
the auto-reply flow has multiple write points that overwrite the session entry,
and making all of them merge-aware proved unreliable.
The working solution patches the label in the `finally` block of
`runSubagentAnnounceFlow`, after all other session writes complete.
This is a workaround but robust - the patch happens at the very end,
just before potential cleanup.
A future refactor could make session writes consistently merge-based,
which would allow the cleaner approach of setting label at spawn time.
```typescript
// Spawn with label
sessions_spawn({ task: "...", label: "my-worker" })
// Later, find by label
sessions_send({ label: "my-worker", message: "continue..." })
// Or use sessions_list to see labels
sessions_list() // includes label field in response
```
217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
import { lookupContextTokens } from "../../agents/context.js";
|
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
|
import { loadModelCatalog } from "../../agents/model-catalog.js";
|
|
import {
|
|
buildAllowedModelSet,
|
|
type ModelAliasIndex,
|
|
modelKey,
|
|
resolveModelRefFromString,
|
|
resolveThinkingDefault,
|
|
} from "../../agents/model-selection.js";
|
|
import type { ClawdbotConfig } from "../../config/config.js";
|
|
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
|
import type { ThinkLevel } from "./directives.js";
|
|
|
|
export type ModelDirectiveSelection = {
|
|
provider: string;
|
|
model: string;
|
|
isDefault: boolean;
|
|
alias?: string;
|
|
};
|
|
|
|
type ModelCatalog = Awaited<ReturnType<typeof loadModelCatalog>>;
|
|
|
|
type ModelSelectionState = {
|
|
provider: string;
|
|
model: string;
|
|
allowedModelKeys: Set<string>;
|
|
allowedModelCatalog: ModelCatalog;
|
|
resetModelOverride: boolean;
|
|
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
|
needsModelCatalog: boolean;
|
|
};
|
|
|
|
export async function createModelSelectionState(params: {
|
|
cfg: ClawdbotConfig;
|
|
agentCfg:
|
|
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
|
| undefined;
|
|
sessionEntry?: SessionEntry;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
sessionKey?: string;
|
|
storePath?: string;
|
|
defaultProvider: string;
|
|
defaultModel: string;
|
|
provider: string;
|
|
model: string;
|
|
hasModelDirective: boolean;
|
|
}): Promise<ModelSelectionState> {
|
|
const {
|
|
cfg,
|
|
agentCfg,
|
|
sessionEntry,
|
|
sessionStore,
|
|
sessionKey,
|
|
storePath,
|
|
defaultProvider,
|
|
defaultModel,
|
|
} = params;
|
|
|
|
let provider = params.provider;
|
|
let model = params.model;
|
|
|
|
const hasAllowlist =
|
|
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
|
const hasStoredOverride = Boolean(
|
|
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
|
|
);
|
|
const needsModelCatalog =
|
|
params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
|
|
|
let allowedModelKeys = new Set<string>();
|
|
let allowedModelCatalog: ModelCatalog = [];
|
|
let modelCatalog: ModelCatalog | null = null;
|
|
let resetModelOverride = false;
|
|
|
|
if (needsModelCatalog) {
|
|
modelCatalog = await loadModelCatalog({ config: cfg });
|
|
const allowed = buildAllowedModelSet({
|
|
cfg,
|
|
catalog: modelCatalog,
|
|
defaultProvider,
|
|
defaultModel,
|
|
});
|
|
allowedModelCatalog = allowed.allowedCatalog;
|
|
allowedModelKeys = allowed.allowedKeys;
|
|
}
|
|
|
|
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
|
|
const overrideProvider =
|
|
sessionEntry.providerOverride?.trim() || defaultProvider;
|
|
const overrideModel = sessionEntry.modelOverride?.trim();
|
|
if (overrideModel) {
|
|
const key = modelKey(overrideProvider, overrideModel);
|
|
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
|
delete sessionEntry.providerOverride;
|
|
delete sessionEntry.modelOverride;
|
|
sessionEntry.updatedAt = Date.now();
|
|
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
|
if (storePath) {
|
|
await saveSessionStore(storePath, sessionStore);
|
|
}
|
|
resetModelOverride = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
|
|
const storedModelOverride = sessionEntry?.modelOverride?.trim();
|
|
if (storedModelOverride) {
|
|
const candidateProvider = storedProviderOverride || defaultProvider;
|
|
const key = modelKey(candidateProvider, storedModelOverride);
|
|
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
|
provider = candidateProvider;
|
|
model = storedModelOverride;
|
|
}
|
|
}
|
|
|
|
if (
|
|
sessionEntry &&
|
|
sessionStore &&
|
|
sessionKey &&
|
|
sessionEntry.authProfileOverride
|
|
) {
|
|
const { ensureAuthProfileStore } = await import(
|
|
"../../agents/auth-profiles.js"
|
|
);
|
|
const store = ensureAuthProfileStore();
|
|
const profile = store.profiles[sessionEntry.authProfileOverride];
|
|
if (!profile || profile.provider !== provider) {
|
|
delete sessionEntry.authProfileOverride;
|
|
sessionEntry.updatedAt = Date.now();
|
|
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
|
|
if (storePath) {
|
|
await saveSessionStore(storePath, sessionStore);
|
|
}
|
|
}
|
|
}
|
|
|
|
let defaultThinkingLevel: ThinkLevel | undefined;
|
|
const resolveDefaultThinkingLevel = async () => {
|
|
if (defaultThinkingLevel) return defaultThinkingLevel;
|
|
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
|
|
if (!catalogForThinking || catalogForThinking.length === 0) {
|
|
modelCatalog = await loadModelCatalog({ config: cfg });
|
|
catalogForThinking = modelCatalog;
|
|
}
|
|
defaultThinkingLevel = resolveThinkingDefault({
|
|
cfg,
|
|
provider,
|
|
model,
|
|
catalog: catalogForThinking,
|
|
});
|
|
return defaultThinkingLevel;
|
|
};
|
|
|
|
return {
|
|
provider,
|
|
model,
|
|
allowedModelKeys,
|
|
allowedModelCatalog,
|
|
resetModelOverride,
|
|
resolveDefaultThinkingLevel,
|
|
needsModelCatalog,
|
|
};
|
|
}
|
|
|
|
export function resolveModelDirectiveSelection(params: {
|
|
raw: string;
|
|
defaultProvider: string;
|
|
defaultModel: string;
|
|
aliasIndex: ModelAliasIndex;
|
|
allowedModelKeys: Set<string>;
|
|
}): { selection?: ModelDirectiveSelection; error?: string } {
|
|
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
|
|
params;
|
|
const resolved = resolveModelRefFromString({
|
|
raw,
|
|
defaultProvider,
|
|
aliasIndex,
|
|
});
|
|
if (!resolved) {
|
|
return {
|
|
error: `Unrecognized model "${raw}". Use /model to list available models.`,
|
|
};
|
|
}
|
|
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
|
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
|
return {
|
|
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
|
|
};
|
|
}
|
|
const isDefault =
|
|
resolved.ref.provider === defaultProvider &&
|
|
resolved.ref.model === defaultModel;
|
|
return {
|
|
selection: {
|
|
provider: resolved.ref.provider,
|
|
model: resolved.ref.model,
|
|
isDefault,
|
|
alias: resolved.alias,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function resolveContextTokens(params: {
|
|
agentCfg:
|
|
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
|
|
| undefined;
|
|
model: string;
|
|
}): number {
|
|
return (
|
|
params.agentCfg?.contextTokens ??
|
|
lookupContextTokens(params.model) ??
|
|
DEFAULT_CONTEXT_TOKENS
|
|
);
|
|
}
|