From e5dc0e6d15ea7a49a93ae0e0ea94c50c2c7b2a65 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 05:01:45 +0100 Subject: [PATCH] fix: expose agent runtime status metadata --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 6 +- .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/gateway/protocol.md | 2 +- src/agents/agent-runtime-metadata.ts | 145 ++++++++++++++++++ src/agents/command/session-store.test.ts | 59 ++++++- src/agents/command/session-store.ts | 3 +- src/agents/tools/agents-list-tool.test.ts | 36 ++++- src/agents/tools/agents-list-tool.ts | 51 +----- src/auto-reply/reply/agent-runner.ts | 1 - src/auto-reply/reply/followup-runner.test.ts | 2 +- src/auto-reply/reply/followup-runner.ts | 2 - src/auto-reply/status.test.ts | 74 +++++++++ .../protocol/schema/agents-models-skills.ts | 15 ++ src/gateway/session-utils.test.ts | 62 +++++++- src/gateway/session-utils.ts | 2 + src/shared/session-types.ts | 7 + src/status/status-message.ts | 14 +- ui/src/ui/chat/slash-command-executor.ts | 3 +- ui/src/ui/views/agents-panels-overview.ts | 6 + ui/src/ui/views/agents-panels-status-files.ts | 4 + ui/src/ui/views/agents-utils.test.ts | 2 + ui/src/ui/views/agents-utils.ts | 12 ++ 23 files changed, 451 insertions(+), 64 deletions(-) create mode 100644 src/agents/agent-runtime-metadata.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6401f69a7c..1c92fab139f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog. - Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers. - Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval. +- Agents/runtime status: expose effective agent runtime metadata in `agents.list`, Control UI agent panels, and `/agents`, and avoid rendering stale or cumulative CLI token totals as live context usage. Fixes #73660, #73578, and #45268. Thanks @spartman, @DashLabsDev, and @xyooz. - Agents/transcripts: strip empty assistant text blocks while preserving valid text, images, and signatures, so Anthropic-style providers no longer reject sanitized transcript turns. Fixes #73640. Thanks @jowhee327. - Providers/Bedrock: omit deprecated `temperature` for Claude Opus 4.7 Bedrock model ids, named and application inference profiles, including dotted `opus-4.7` refs, and classify the nested validation response for failover. Fixes #73663. Thanks @bstanbury. - Gateway: raise the preauth/connect-challenge timeout to 15s so cold CLI starts on slower hosts have more time to process the WebSocket challenge before the Gateway closes the connection. Fixes #51469; refs #73592 and #62060. Thanks @GothicFox and @jackychen-png. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 1c902bd8ae9..0a7b7a243af 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -2866,19 +2866,22 @@ public struct AgentSummary: Codable, Sendable { public let identity: [String: AnyCodable]? public let workspace: String? public let model: [String: AnyCodable]? + public let agentruntime: [String: AnyCodable]? public init( id: String, name: String?, identity: [String: AnyCodable]?, workspace: String?, - model: [String: AnyCodable]?) + model: [String: AnyCodable]?, + agentruntime: [String: AnyCodable]?) { self.id = id self.name = name self.identity = identity self.workspace = workspace self.model = model + self.agentruntime = agentruntime } private enum CodingKeys: String, CodingKey { @@ -2887,6 +2890,7 @@ public struct AgentSummary: Codable, Sendable { case identity case workspace case model + case agentruntime = "agentRuntime" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 1c902bd8ae9..0a7b7a243af 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2866,19 +2866,22 @@ public struct AgentSummary: Codable, Sendable { public let identity: [String: AnyCodable]? public let workspace: String? public let model: [String: AnyCodable]? + public let agentruntime: [String: AnyCodable]? public init( id: String, name: String?, identity: [String: AnyCodable]?, workspace: String?, - model: [String: AnyCodable]?) + model: [String: AnyCodable]?, + agentruntime: [String: AnyCodable]?) { self.id = id self.name = name self.identity = identity self.workspace = workspace self.model = model + self.agentruntime = agentruntime } private enum CodingKeys: String, CodingKey { @@ -2887,6 +2890,7 @@ public struct AgentSummary: Codable, Sendable { case identity case workspace case model + case agentruntime = "agentRuntime" } } diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 6a07dc8874a..021581cf84e 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -378,7 +378,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - - `agents.list` returns configured agent entries. + - `agents.list` returns configured agent entries, including effective model and runtime metadata. - `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring. - `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent. - `agent.identity.get` returns the effective assistant identity for an agent or session. diff --git a/src/agents/agent-runtime-metadata.ts b/src/agents/agent-runtime-metadata.ts new file mode 100644 index 00000000000..9af8c2d1dde --- /dev/null +++ b/src/agents/agent-runtime-metadata.ts @@ -0,0 +1,145 @@ +import type { AgentRuntimePolicyConfig } from "../config/types.agents-shared.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { resolveAgentRuntimePolicy } from "./agent-runtime-policy.js"; +import { listAgentEntries } from "./agent-scope.js"; +import { + normalizeEmbeddedAgentRuntime, + resolveEmbeddedAgentHarnessFallback, + type EmbeddedAgentHarnessFallback, + type EmbeddedAgentRuntime, +} from "./pi-embedded-runner/runtime.js"; + +export type AgentRuntimeMetadata = { + id: string; + fallback?: "pi" | "none"; + source: "env" | "agent" | "defaults" | "implicit"; +}; + +function normalizeRuntimeValue(value: unknown): EmbeddedAgentRuntime | undefined { + const normalized = typeof value === "string" ? normalizeLowercaseStringOrEmpty(value) : ""; + return normalized ? normalizeEmbeddedAgentRuntime(normalized) : undefined; +} + +function normalizeAgentHarnessFallback( + value: EmbeddedAgentHarnessFallback | undefined, + runtime: EmbeddedAgentRuntime, +): EmbeddedAgentHarnessFallback { + if (value) { + return value === "none" ? "none" : "pi"; + } + return runtime === "auto" ? "pi" : "none"; +} + +function isPluginAgentRuntime(runtime: string): boolean { + return runtime !== "auto" && runtime !== "pi"; +} + +function resolveEffectiveFallback(params: { + envFallback?: EmbeddedAgentHarnessFallback; + envRuntime?: string; + runtime: EmbeddedAgentRuntime; + agentPolicy?: AgentRuntimePolicyConfig; + defaultsPolicy?: AgentRuntimePolicyConfig; +}): EmbeddedAgentHarnessFallback | undefined { + if (params.envFallback) { + return params.envFallback; + } + + if (params.envRuntime && isPluginAgentRuntime(params.runtime)) { + return normalizeAgentHarnessFallback(undefined, params.runtime); + } + + if (params.agentPolicy?.id) { + return normalizeAgentHarnessFallback(params.agentPolicy.fallback, params.runtime); + } + + if ( + params.envRuntime || + params.defaultsPolicy?.id || + params.agentPolicy?.fallback || + params.defaultsPolicy?.fallback + ) { + return normalizeAgentHarnessFallback( + params.agentPolicy?.fallback ?? params.defaultsPolicy?.fallback, + params.runtime, + ); + } + + return undefined; +} + +export function resolveAgentRuntimeMetadata( + cfg: OpenClawConfig, + agentId: string, + env: NodeJS.ProcessEnv = process.env, +): AgentRuntimeMetadata { + const envFallback = resolveEmbeddedAgentHarnessFallback(env); + const envRuntime = normalizeRuntimeValue(env.OPENCLAW_AGENT_RUNTIME); + const normalizedAgentId = normalizeAgentId(agentId); + const agentEntry = listAgentEntries(cfg).find( + (entry) => normalizeAgentId(entry.id) === normalizedAgentId, + ); + const agentPolicy = resolveAgentRuntimePolicy(agentEntry); + const defaultsPolicy = resolveAgentRuntimePolicy(cfg.agents?.defaults); + + if (envRuntime) { + return { + id: envRuntime, + fallback: resolveEffectiveFallback({ + envFallback, + envRuntime, + runtime: envRuntime, + agentPolicy, + defaultsPolicy, + }), + source: "env", + }; + } + + const agentRuntime = normalizeRuntimeValue(agentPolicy?.id); + if (agentRuntime) { + return { + id: agentRuntime, + fallback: resolveEffectiveFallback({ + envFallback, + runtime: agentRuntime, + agentPolicy, + defaultsPolicy, + }), + source: envFallback ? "env" : "agent", + }; + } + + const defaultsRuntime = normalizeRuntimeValue(defaultsPolicy?.id); + if (defaultsRuntime) { + return { + id: defaultsRuntime, + fallback: resolveEffectiveFallback({ + envFallback, + runtime: defaultsRuntime, + agentPolicy, + defaultsPolicy, + }), + source: envFallback ? "env" : agentPolicy?.fallback ? "agent" : "defaults", + }; + } + + return { + id: "pi", + fallback: resolveEffectiveFallback({ + envFallback, + runtime: "pi", + agentPolicy, + defaultsPolicy, + }), + source: envFallback + ? "env" + : agentPolicy?.fallback + ? "agent" + : defaultsPolicy?.fallback + ? "defaults" + : "implicit", + }; +} diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 848769480c7..7df0211bd8a 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -460,7 +460,7 @@ describe("updateSessionStoreAfterAgentRun", () => { agents: { defaults: { cliBackends: { - "claude-cli": {}, + "claude-cli": { command: "claude" }, }, }, }, @@ -564,6 +564,63 @@ describe("updateSessionStoreAfterAgentRun", () => { }); }); + it("does not treat CLI cumulative usage as a fresh context snapshot", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = { + agents: { + defaults: { + cliBackends: { + "claude-cli": { command: "claude" }, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-cli-cumulative-usage"; + const sessionId = "test-cli-cumulative-usage-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + totalTokens: 95_000, + totalTokensFresh: true, + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + await updateSessionStoreAfterAgentRun({ + cfg, + contextTokensOverride: 1_000_000, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "claude-cli", + defaultModel: "claude-opus-4-7", + result: { + meta: { + durationMs: 1, + executionTrace: { runner: "cli" }, + agentMeta: { + sessionId, + provider: "claude-cli", + model: "claude-opus-4-7", + usage: { + input: 3_800_000, + output: 20_000, + total: 3_820_000, + }, + }, + }, + }, + }); + + expect(sessionStore[sessionKey]?.inputTokens).toBe(3_800_000); + expect(sessionStore[sessionKey]?.outputTokens).toBe(20_000); + expect(sessionStore[sessionKey]?.totalTokens).toBeUndefined(); + expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false); + }); + }); + it("persists compaction tokensAfter when provider usage is unavailable", async () => { await withTempSessionStore(async ({ storePath }) => { const cfg = {} as OpenClawConfig; diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index d8112cae710..a91aba5f856 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -133,8 +133,9 @@ export async function updateSessionStoreAfterAgentRun(params: { const { estimateUsageCost, resolveModelCostConfig } = await getUsageFormatModule(); const input = usage.input ?? 0; const output = usage.output ?? 0; + const usageForContext = isCliProvider(providerUsed, cfg) ? undefined : usage; const totalTokens = deriveSessionTotalTokens({ - usage: promptTokens ? undefined : usage, + usage: promptTokens ? undefined : usageForContext, contextTokens, promptTokens, }); diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index 8aff1ecd48c..3ce8b82a4a0 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -83,12 +83,14 @@ describe("agents_list tool", () => { }); }); - it("marks OPENCLAW_AGENT_RUNTIME as the effective runtime source", async () => { + it("marks OPENCLAW_AGENT_RUNTIME and fallback env overrides as effective", async () => { vi.stubEnv("OPENCLAW_AGENT_RUNTIME", "codex"); + vi.stubEnv("OPENCLAW_AGENT_HARNESS_FALLBACK", "pi"); loadConfigMock.mockReturnValue({ agents: { defaults: { model: "openai/gpt-5.5", + agentRuntime: { fallback: "none" }, }, list: [{ id: "main", default: true }], }, @@ -104,7 +106,37 @@ describe("agents_list tool", () => { agents: [ { id: "main", - agentRuntime: { id: "codex", source: "env" }, + agentRuntime: { id: "codex", fallback: "pi", source: "env" }, + }, + ], + }); + }); + + it("preserves agent fallback-only overrides while inheriting default runtime id", async () => { + loadConfigMock.mockReturnValue({ + agents: { + defaults: { + agentRuntime: { id: "auto", fallback: "pi" }, + subagents: { allowAgents: ["strict"] }, + }, + list: [ + { id: "main", default: true }, + { id: "strict", agentRuntime: { fallback: "none" } }, + ], + }, + } satisfies OpenClawConfig); + + const { createAgentsListTool } = await import("./agents-list-tool.js"); + const result = await createAgentsListTool({ agentSessionKey: "agent:main:main" }).execute( + "call", + {}, + ); + + expect(result.details).toMatchObject({ + agents: [ + { + id: "strict", + agentRuntime: { id: "auto", fallback: "none", source: "agent" }, }, ], }); diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 86a975eff2b..5e05ddc7c9d 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -5,12 +5,8 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; -import { resolveAgentRuntimePolicy } from "../agent-runtime-policy.js"; -import { - listAgentEntries, - resolveAgentConfig, - resolveAgentEffectiveModelPrimary, -} from "../agent-scope.js"; +import { resolveAgentRuntimeMetadata } from "../agent-runtime-metadata.js"; +import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js"; import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult } from "./common.js"; @@ -30,49 +26,6 @@ type AgentListEntry = { }; }; -function normalizeRuntimeValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined; -} - -function resolveAgentRuntimeMetadata( - cfg: ReturnType, - agentId: string, -): NonNullable { - const envRuntime = normalizeRuntimeValue(process.env.OPENCLAW_AGENT_RUNTIME); - if (envRuntime) { - return { - id: envRuntime, - source: "env", - }; - } - - const agentEntry = listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === agentId); - const agentPolicy = resolveAgentRuntimePolicy(agentEntry); - const agentRuntime = normalizeRuntimeValue(agentPolicy?.id); - if (agentRuntime) { - return { - id: agentRuntime, - fallback: agentPolicy?.fallback, - source: "agent", - }; - } - - const defaultsPolicy = resolveAgentRuntimePolicy(cfg.agents?.defaults); - const defaultsRuntime = normalizeRuntimeValue(defaultsPolicy?.id); - if (defaultsRuntime) { - return { - id: defaultsRuntime, - fallback: defaultsPolicy?.fallback, - source: "defaults", - }; - } - - return { - id: "pi", - source: "implicit", - }; -} - export function createAgentsListTool(opts?: { agentSessionKey?: string; /** Explicit agent ID override for cron/hook sessions. */ diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 0214795c60e..16802e5c83d 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1316,7 +1316,6 @@ export async function runReplyAgent(params: { systemPromptReport: runResult.meta?.systemPromptReport, cliSessionId, cliSessionBinding, - usageIsContextSnapshot: isCliProvider(providerUsed, cfg), }); // Drain any late tool/block deliveries before deciding there's "nothing to send". diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index f601962594b..63dca89b3f4 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1287,9 +1287,9 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { expect(persistSpy).toHaveBeenCalledWith( expect.objectContaining({ providerUsed: "anthropic", - usageIsContextSnapshot: true, }), ); + expect(persistSpy.mock.calls[0]?.[0]?.usageIsContextSnapshot).toBeUndefined(); persistSpy.mockRestore(); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index a749c51f798..36b4ad3fd88 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -7,7 +7,6 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu import { resolveContextTokensForModel } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; -import { isCliProvider } from "../../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; import { buildAgentRuntimeDeliveryPlan, @@ -401,7 +400,6 @@ export function createFollowupRunner(params: { contextTokensUsed, systemPromptReport: runResult.meta?.systemPromptReport, cliSessionBinding: runResult.meta?.agentMeta?.cliSessionBinding, - usageIsContextSnapshot: isCliProvider(providerUsed, runtimeConfig), logLabel: "followup", }); } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 0d0a7663af1..528bf303e48 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -103,6 +103,33 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Queue: collect"); }); + it("does not render stale totalTokens as current context usage", () => { + const text = buildStatusMessage({ + agent: { + model: "anthropic/pi:opus", + contextTokens: 1_000_000, + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + inputTokens: 3_800_000, + outputTokens: 20_000, + totalTokens: 3_800_000, + totalTokensFresh: false, + contextTokens: 1_000_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", + now: 10 * 60_000, + }); + const normalized = normalizeTestText(text); + + expect(normalized).toContain("Context: ?/1.0m"); + expect(normalized).not.toContain("Context: 3.8m/1.0m"); + }); + it("shows sanitized TTS provider details in the voice status line", async () => { await withTempHome(async () => { const text = buildStatusMessage({ @@ -1433,6 +1460,53 @@ describe("buildStatusMessage", () => { ); }); + it("does not render stale context usage from transcript fallback", async () => { + await withTempHome( + async (dir) => { + const sessionId = "sess-stale-transcript-context"; + writeTranscriptUsageLog({ + dir, + agentId: "main", + sessionId, + usage: { + input: 3_800_000, + output: 20_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3_820_000, + }, + }); + + const text = buildStatusMessage({ + agent: { + model: "anthropic/claude-opus-4-6", + contextTokens: 1_000_000, + }, + sessionEntry: { + sessionId, + updatedAt: 0, + inputTokens: 3_800_000, + outputTokens: 20_000, + totalTokens: 3_800_000, + totalTokensFresh: false, + contextTokens: 1_000_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, + modelAuth: "api-key", + }); + const normalized = normalizeTestText(text); + + expect(normalized).toContain("Context: ?/1.0m"); + expect(normalized).not.toContain("Context: 3.8m/1.0m"); + expect(normalized).not.toContain("Context: 3.82m/1.0m"); + }, + { prefix: "openclaw-status-" }, + ); + }); + it("reads transcript usage for non-default agents", async () => { await withTempHome( async (dir) => { diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index 5595f3d0d8c..e7b15d6ba92 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -39,6 +39,21 @@ export const AgentSummarySchema = Type.Object( { additionalProperties: false }, ), ), + agentRuntime: Type.Optional( + Type.Object( + { + id: NonEmptyString, + fallback: Type.Optional(Type.Union([Type.Literal("pi"), Type.Literal("none")])), + source: Type.Union([ + Type.Literal("env"), + Type.Literal("agent"), + Type.Literal("defaults"), + Type.Literal("implicit"), + ]), + }, + { additionalProperties: false }, + ), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 018569c0bc3..342b0d979e5 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -842,8 +842,9 @@ describe("gateway session utils", () => { primary: "openai/gpt-5.4", fallbacks: ["openai-codex/gpt-5.4"], }, + agentRuntime: { id: "pi", fallback: "pi" }, }, - list: [{ id: "main", default: true }], + list: [{ id: "main", default: true, agentRuntime: { id: "claude-cli", fallback: "none" } }], }, } as OpenClawConfig; @@ -855,6 +856,65 @@ describe("gateway session utils", () => { primary: "openai/gpt-5.4", fallbacks: ["openai-codex/gpt-5.4"], }, + agentRuntime: { + id: "claude-cli", + fallback: "none", + source: "agent", + }, + }); + }); + + test("listAgentsForGateway reports effective env runtime fallback override", () => { + const previousFallback = process.env.OPENCLAW_AGENT_HARNESS_FALLBACK; + process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "pi"; + try { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + agentRuntime: { id: "codex", fallback: "none" }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; + + const result = listAgentsForGateway(cfg); + expect(result.agents[0]).toMatchObject({ + id: "main", + agentRuntime: { + id: "codex", + fallback: "pi", + source: "env", + }, + }); + } finally { + if (previousFallback === undefined) { + delete process.env.OPENCLAW_AGENT_HARNESS_FALLBACK; + } else { + process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = previousFallback; + } + } + }); + + test("listAgentsForGateway preserves fallback-only agent runtime overrides", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { + defaults: { + agentRuntime: { id: "auto", fallback: "pi" }, + }, + list: [{ id: "main", default: true, agentRuntime: { fallback: "none" } }], + }, + } as OpenClawConfig; + + const result = listAgentsForGateway(cfg); + expect(result.agents[0]).toMatchObject({ + id: "main", + agentRuntime: { + id: "auto", + fallback: "none", + source: "agent", + }, }); }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 8ae762ad93e..787f7336b5c 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js"; import { listAgentIds, resolveAgentConfig, @@ -794,6 +795,7 @@ export function listAgentsForGateway(cfg: OpenClawConfig): { name: meta?.name, identity: meta?.identity, workspace: resolveAgentWorkspaceDir(cfg, id), + agentRuntime: resolveAgentRuntimeMetadata(cfg, id), }, model ? { model } : {}, ); diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 628c9e7d50e..dd06fc8d6a3 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -11,12 +11,19 @@ export type GatewayAgentModel = { fallbacks?: string[]; }; +export type GatewayAgentRuntime = { + id: string; + fallback?: "pi" | "none"; + source: "env" | "agent" | "defaults" | "implicit"; +}; + export type GatewayAgentRow = { id: string; name?: string; identity?: GatewayAgentIdentity; workspace?: string; model?: GatewayAgentModel; + agentRuntime?: GatewayAgentRuntime; }; export type SessionsListResultBase = { diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 825debe69ab..157558fd2b4 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -28,6 +28,7 @@ import { resolveSessionFilePathOptions, resolveSessionPluginStatusLines, resolveSessionPluginTraceLines, + resolveFreshSessionTotalTokens, type SessionEntry, type SessionScope, } from "../config/sessions.js"; @@ -571,7 +572,13 @@ export function buildStatusMessage(args: StatusArgs): string { let outputTokens = entry?.outputTokens; let cacheRead = entry?.cacheRead; let cacheWrite = entry?.cacheWrite; - let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); + const freshTotalTokens = resolveFreshSessionTotalTokens(entry); + const allowTranscriptContextUsage = entry?.totalTokensFresh !== false; + let totalTokens = + freshTotalTokens ?? + (entry?.totalTokensFresh === false + ? undefined + : (entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0))); // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). @@ -585,7 +592,10 @@ export function buildStatusMessage(args: StatusArgs): string { ); if (logUsage) { const candidate = logUsage.promptTokens || logUsage.total; - if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { + if ( + allowTranscriptContextUsage && + (!totalTokens || totalTokens === 0 || candidate > totalTokens) + ) { totalTokens = candidate; } if (!entry?.model && logUsage.model) { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 8f1427bd889..a2d9f7fc945 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -426,7 +426,8 @@ async function executeAgents(client: GatewayBrowserClient): PromisePrimary Model
${model}
+
+
Runtime
+
${runtime}
+
Skills Filter
${skillFilter ? `${skillCount} selected` : "all skills"}
diff --git a/ui/src/ui/views/agents-panels-status-files.ts b/ui/src/ui/views/agents-panels-status-files.ts index db7fd5caaee..4790625a5b7 100644 --- a/ui/src/ui/views/agents-panels-status-files.ts +++ b/ui/src/ui/views/agents-panels-status-files.ts @@ -112,6 +112,10 @@ function renderAgentContextCard(
Primary Model
${context.model}
+
+
Runtime
+
${context.runtime}
+
Identity Name
${context.identityName}
diff --git a/ui/src/ui/views/agents-utils.test.ts b/ui/src/ui/views/agents-utils.test.ts index 47371664359..402ad1d0550 100644 --- a/ui/src/ui/views/agents-utils.test.ts +++ b/ui/src/ui/views/agents-utils.test.ts @@ -204,6 +204,7 @@ describe("buildAgentContext", () => { primary: "openai/gpt-5.5", fallbacks: ["openai-codex/gpt-5.2-codex"], }, + agentRuntime: { id: "claude-cli", fallback: "none", source: "agent" }, }, null, null, @@ -213,6 +214,7 @@ describe("buildAgentContext", () => { expect(context.workspace).toBe("/tmp/agent-workspace"); expect(context.model).toBe("openai/gpt-5.5 (+1 fallback)"); + expect(context.runtime).toBe("claude-cli (fallback none)"); expect(context.isDefault).toBe(true); }); diff --git a/ui/src/ui/views/agents-utils.ts b/ui/src/ui/views/agents-utils.ts index 162305929ed..58612b94270 100644 --- a/ui/src/ui/views/agents-utils.ts +++ b/ui/src/ui/views/agents-utils.ts @@ -167,6 +167,7 @@ type AgentConfigEntry = { workspace?: string; agentDir?: string; model?: unknown; + agentRuntime?: unknown; skills?: string[]; tools?: { profile?: string; @@ -386,6 +387,7 @@ export function resolveAgentConfig(config: Record | null, agent export type AgentContext = { workspace: string; model: string; + runtime: string; identityName: string; identityAvatar: string; skillsLabel: string; @@ -413,6 +415,7 @@ export function buildAgentContext( : config.defaults?.model ? resolveModelLabel(config.defaults?.model) : resolveModelLabel(agent.model); + const runtime = resolveAgentRuntimeLabel(agent.agentRuntime); const identityName = normalizeOptionalString(agent.identity?.name) || normalizeOptionalString(agent.name) || @@ -427,6 +430,7 @@ export function buildAgentContext( return { workspace, model: modelLabel, + runtime, identityName, identityAvatar, skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", @@ -434,6 +438,14 @@ export function buildAgentContext( }; } +export function resolveAgentRuntimeLabel( + agentRuntime?: AgentsListResult["agents"][number]["agentRuntime"], +): string { + const id = normalizeOptionalString(agentRuntime?.id) ?? "pi"; + const fallback = normalizeOptionalString(agentRuntime?.fallback); + return fallback ? `${id} (fallback ${fallback})` : id; +} + export function resolveModelLabel(model?: unknown): string { if (!model) { return "-";