diff --git a/CHANGELOG.md b/CHANGELOG.md index 120609202..3ef045031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210) - Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994 - Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj +- Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess diff --git a/src/agents/clawdbot-tools.session-status.test.ts b/src/agents/clawdbot-tools.session-status.test.ts new file mode 100644 index 000000000..ce29b1486 --- /dev/null +++ b/src/agents/clawdbot-tools.session-status.test.ts @@ -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(); + return { + ...actual, + loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), + saveSessionStore: (storePath: string, store: Record) => + saveSessionStoreMock(storePath, store), + resolveStorePath: () => "/tmp/sessions.json", + }; +}); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + 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, + ]; + const saved = savedStore.main as Record; + expect(saved.providerOverride).toBeUndefined(); + expect(saved.modelOverride).toBeUndefined(); + expect(saved.authProfileOverride).toBeUndefined(); + }); +}); diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index f63b6e787..0b5287e60 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -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] : []), ]; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 0d8e1ade0..abc01ac2a 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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", ]; diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 71ce6da81..5ab8a8483 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -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", diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts new file mode 100644 index 000000000..261ce52a0 --- /dev/null +++ b/src/agents/tools/session-status-tool.ts @@ -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; + 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([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; + 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, + }, + }; + }, + }; +}