feat(agents): add session_status tool

This commit is contained in:
Peter Steinberger
2026-01-09 21:09:34 +01:00
parent d309d4fe8b
commit 4121f9e6dc
6 changed files with 532 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
const loadSessionStoreMock = vi.fn();
const saveSessionStoreMock = vi.fn();
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
saveSessionStore: (storePath: string, store: Record<string, unknown>) =>
saveSessionStoreMock(storePath, store),
resolveStorePath: () => "/tmp/sessions.json",
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
models: {},
},
},
}),
};
});
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: async () => [
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Opus",
contextWindow: 200000,
},
{
provider: "anthropic",
id: "claude-sonnet-4-5",
name: "Sonnet",
contextWindow: 200000,
},
],
}));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore: () => ({ profiles: {} }),
resolveAuthProfileDisplayLabel: () => undefined,
resolveAuthProfileOrder: () => [],
}));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey: () => null,
getCustomProviderApiKey: () => null,
resolveModelAuthMode: () => "api-key",
}));
vi.mock("../infra/provider-usage.js", () => ({
resolveUsageProviderId: () => undefined,
loadProviderUsageSummary: async () => ({
updatedAt: Date.now(),
providers: [],
}),
formatUsageSummaryLine: () => null,
}));
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
it("returns a status card for the current session", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
updatedAt: 10,
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
const result = await tool.execute("call1", {});
const details = result.details as { ok?: boolean; statusText?: string };
expect(details.ok).toBe(true);
expect(details.statusText).toContain("ClawdBot");
expect(details.statusText).toContain("🧠 Model:");
});
it("errors for unknown session keys", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: { sessionId: "s1", updatedAt: 10 },
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionKey",
);
expect(saveSessionStoreMock).not.toHaveBeenCalled();
});
it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset();
saveSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
main: {
sessionId: "s1",
updatedAt: 10,
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
authProfileOverride: "p1",
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
await tool.execute("call3", { model: "default" });
expect(saveSessionStoreMock).toHaveBeenCalled();
const [, savedStore] = saveSessionStoreMock.mock.calls.at(-1) as [
string,
Record<string, unknown>,
];
const saved = savedStore.main as Record<string, unknown>;
expect(saved.providerOverride).toBeUndefined();
expect(saved.modelOverride).toBeUndefined();
expect(saved.authProfileOverride).toBeUndefined();
});
});

View File

@@ -8,6 +8,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
@@ -58,6 +59,10 @@ export function createClawdbotTools(options?: {
agentProvider: options?.agentProvider,
sandboxed: options?.sandboxed,
}),
createSessionStatusTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
...(imageTool ? [imageTool] : []),
];
}

View File

@@ -53,6 +53,8 @@ export function buildAgentSystemPrompt(params: {
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (includes usage + cost when available); optional per-session model override",
image: "Analyze an image with the configured image model",
};
@@ -76,6 +78,7 @@ export function buildAgentSystemPrompt(params: {
"sessions_list",
"sessions_history",
"sessions_send",
"session_status",
"image",
];

View File

@@ -212,6 +212,11 @@
"title": "Sub-agent",
"detailKeys": ["label", "agentId", "runTimeoutSeconds", "cleanup"]
},
"session_status": {
"emoji": "📊",
"title": "Session Status",
"detailKeys": ["sessionKey", "model"]
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",

View File

@@ -0,0 +1,372 @@
import { Type } from "@sinclair/typebox";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import {
getCustomProviderApiKey,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
modelKey,
normalizeProviderId,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import {
getFollowupQueueDepth,
resolveQueueSettings,
} from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import {
formatUsageSummaryLine,
loadProviderUsageSummary,
resolveUsageProviderId,
} from "../../infra/provider-usage.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "./sessions-helpers.js";
const SessionStatusToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
});
function formatApiKeySnippet(apiKey: string): string {
const compact = apiKey.replace(/\s+/g, "");
if (!compact) return "unknown";
const edge = compact.length >= 12 ? 6 : 4;
const head = compact.slice(0, edge);
const tail = compact.slice(-edge);
return `${head}${tail}`;
}
function resolveModelAuthLabel(params: {
provider?: string;
cfg: ClawdbotConfig;
sessionEntry?: SessionEntry;
agentDir?: string;
}): string | undefined {
const resolvedProvider = params.provider?.trim();
if (!resolvedProvider) return undefined;
const providerKey = normalizeProviderId(resolvedProvider);
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profileOverride = params.sessionEntry?.authProfileOverride?.trim();
const order = resolveAuthProfileOrder({
cfg: params.cfg,
store,
provider: providerKey,
preferredProfile: profileOverride,
});
const candidates = [profileOverride, ...order].filter(Boolean) as string[];
for (const profileId of candidates) {
const profile = store.profiles[profileId];
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
continue;
}
const label = resolveAuthProfileDisplayLabel({
cfg: params.cfg,
store,
profileId,
});
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
if (profile.type === "token") {
return `token ${formatApiKeySnippet(profile.token)}${label ? ` (${label})` : ""}`;
}
return `api-key ${formatApiKeySnippet(profile.key)}${label ? ` (${label})` : ""}`;
}
const envKey = resolveEnvApiKey(providerKey);
if (envKey?.apiKey) {
if (envKey.source.includes("OAUTH_TOKEN")) {
return `oauth (${envKey.source})`;
}
return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`;
}
const customKey = getCustomProviderApiKey(params.cfg, providerKey);
if (customKey) {
return `api-key ${formatApiKeySnippet(customKey)} (models.json)`;
}
return "unknown";
}
function resolveSessionEntry(params: {
store: Record<string, SessionEntry>;
keyRaw: string;
alias: string;
mainKey: string;
}): { key: string; entry: SessionEntry } | null {
const keyRaw = params.keyRaw.trim();
if (!keyRaw) return null;
const internal = resolveInternalSessionKey({
key: keyRaw,
alias: params.alias,
mainKey: params.mainKey,
});
const candidates = new Set<string>([keyRaw, internal]);
if (!keyRaw.startsWith("agent:")) {
candidates.add(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
candidates.add(`agent:${DEFAULT_AGENT_ID}:${internal}`);
}
if (keyRaw === "main") {
candidates.add(
buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: params.mainKey,
}),
);
}
for (const key of candidates) {
const entry = params.store[key];
if (entry) return { key, entry };
}
return null;
}
async function resolveModelOverride(params: {
cfg: ClawdbotConfig;
raw: string;
sessionEntry?: SessionEntry;
}): Promise<
| { kind: "reset" }
| {
kind: "set";
provider: string;
model: string;
isDefault: boolean;
}
> {
const raw = params.raw.trim();
if (!raw) return { kind: "reset" };
if (raw.toLowerCase() === "default") return { kind: "reset" };
const configDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const currentProvider =
params.sessionEntry?.providerOverride?.trim() || configDefault.provider;
const currentModel =
params.sessionEntry?.modelOverride?.trim() || configDefault.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: currentProvider,
});
const catalog = await loadModelCatalog({ config: params.cfg });
const allowed = buildAllowedModelSet({
cfg: params.cfg,
catalog,
defaultProvider: currentProvider,
defaultModel: currentModel,
});
const resolved = resolveModelRefFromString({
raw,
defaultProvider: currentProvider,
aliasIndex,
});
if (!resolved) {
throw new Error(`Unrecognized model "${raw}".`);
}
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowed.allowedKeys.size > 0 && !allowed.allowedKeys.has(key)) {
throw new Error(`Model "${key}" is not allowed.`);
}
const isDefault =
resolved.ref.provider === configDefault.provider &&
resolved.ref.model === configDefault.model;
return {
kind: "set",
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
};
}
export function createSessionStatusTool(opts?: {
agentSessionKey?: string;
config?: ClawdbotConfig;
}): AnyAgentTool {
return {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = opts?.config ?? loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const requestedKeyRaw =
readStringParam(params, "sessionKey") ?? opts?.agentSessionKey;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
const agentId = resolveAgentIdFromSessionKey(
opts?.agentSessionKey ?? requestedKeyRaw,
);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const resolved = resolveSessionEntry({
store,
keyRaw: requestedKeyRaw,
alias,
mainKey,
});
if (!resolved) {
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
}
const modelRaw = readStringParam(params, "model");
let changedModel = false;
if (typeof modelRaw === "string") {
const selection = await resolveModelOverride({
cfg,
raw: modelRaw,
sessionEntry: resolved.entry,
});
const nextEntry: SessionEntry = {
...resolved.entry,
updatedAt: Date.now(),
};
if (selection.kind === "reset" || selection.isDefault) {
delete nextEntry.providerOverride;
delete nextEntry.modelOverride;
delete nextEntry.authProfileOverride;
} else {
nextEntry.providerOverride = selection.provider;
nextEntry.modelOverride = selection.model;
delete nextEntry.authProfileOverride;
}
store[resolved.key] = nextEntry;
await saveSessionStore(storePath, store);
resolved.entry = nextEntry;
changedModel = true;
}
const agentDir = resolveAgentDir(cfg, agentId);
const configured = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const providerForCard =
resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;
if (usageProvider) {
try {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
agentDir,
});
const formatted = formatUsageSummaryLine(usageSummary, {
now: Date.now(),
});
if (formatted) usageLine = formatted;
} catch {
// ignore
}
}
const isGroup =
resolved.entry.chatType === "group" ||
resolved.entry.chatType === "room" ||
resolved.key.startsWith("group:") ||
resolved.key.includes(":group:") ||
resolved.key.includes(":channel:");
const groupActivation = isGroup
? (normalizeGroupActivation(resolved.entry.groupActivation) ??
"mention")
: undefined;
const queueSettings = resolveQueueSettings({
cfg,
provider:
resolved.entry.provider ?? resolved.entry.lastProvider ?? "unknown",
sessionEntry: resolved.entry,
});
const queueKey = resolved.key ?? resolved.entry.sessionId;
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
const queueOverrides = Boolean(
resolved.entry.queueDebounceMs ??
resolved.entry.queueCap ??
resolved.entry.queueDrop,
);
const statusText = buildStatusMessage({
config: cfg,
agent: cfg.agents?.defaults ?? {},
sessionEntry: resolved.entry,
sessionKey: resolved.key,
groupActivation,
modelAuth: resolveModelAuthLabel({
provider: providerForCard,
cfg,
sessionEntry: resolved.entry,
agentDir,
}),
usageLine,
queue: {
mode: queueSettings.mode,
depth: queueDepth,
debounceMs: queueSettings.debounceMs,
cap: queueSettings.cap,
dropPolicy: queueSettings.dropPolicy,
showDetails: queueOverrides,
},
includeTranscriptUsage: false,
});
return {
content: [{ type: "text", text: statusText }],
details: {
ok: true,
sessionKey: resolved.key,
changedModel,
statusText,
},
};
},
};
}