mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agents): move implicit-main fallback into load-time roster injection (#112678)
* refactor(agents): require explicit roster defaults * feat(onboard): create named first roster agent * refactor(agents): remove runtime main fallbacks * style(agents): apply roster refactor formatting * refactor(agents): finish roster-only runtime sweep * fix(doctor): migrate legacy main session sqlite * fix(doctor): harden roster session migrations * fix(onboard): commit first agent atomically * fix(config): support empty-roster analysis * fix(agents): preserve legacy main state during creation * fix(setup): materialize baseline agent roster * fix(agents): harden legacy default transfer recovery * fix(agents): simplify roster-only legacy compatibility * fix(agents): preserve staged first-agent entries * fix(config): migrate persisted implicit-main rosters * fix(config): preserve staged empty rosters * fix(agents): finalize roster-only upgrade paths * fix(sessions): close legacy main migration outcomes * fix(config): migrate legacy roster markers at load * fix(sessions): preserve roster upgrade history * refactor(sessions): restore lean legacy main compatibility * fix(setup): prepare first-agent credentials before publish * fix(config): stabilize roster snapshot migration * refactor(sessions): shrink legacy main compatibility * fix(agents): restore roster compatibility fidelity * fix(sessions): preserve divergent legacy history * refactor(agents): narrow roster-only scope * fix(config): isolate roster migration * test(agents): align roster-only fixtures * fix(agents): keep main agent undeletable * fix(agents): harden roster migration invariants * fix(agents): close setup and audit scope gaps * fix(cron): scope session reaper throttles by agent * fix(agents): preserve scoped owner precedence * fix(config): preserve authored config ownership * fix(setup): keep default workspace and roster in sync * fix(setup): preserve default entry workspace on bare runs * fix(agents): adapt roster rebase to keyed entries * fix(agents): honor both roster representations * fix(agents): route roster reads through shared helpers * fix(config): preserve canonical roster writes * fix(cron): resolve dynamic default for session reaper * fix(agents): close dynamic default migration gaps * fix(agents): align scoped session ownership * fix(sessions): preserve legacy main directory casing * fix(agents): align cron and legacy auth ownership * fix(setup): provision the committed default workspace * fix(cron): align scoped ownership and reaping * fix(cron): treat blank agent ids as absent * fix(cron): retain configured session-store owners * fix(agents): repair roster-aware CI boundaries * fix(cron): preserve scoped ownership resolution * fix(agents): preserve rosterless maintenance paths * fix(agents): propagate roster ownership through runtime boundaries * fix(agents): preserve roster ownership across runtime paths * fix(agents): harden roster diagnostics and legacy routing * fix(agents): remove redundant diagnostic import * test(agents): type CLI policy fixture explicitly * fix(config): preserve canonical roster mutation identity * fix(doctor): read canonical agent rosters consistently * fix(config): resolve compound roster unsets safely * fix(config): finalize main-session reconciliation * fix(doctor): read canonical session state safely * fix(sessions): preserve current visibility alias * fix(config): track roster include provenance * test(config): type roster provenance cases * fix(config): refine roster include ownership * fix(agents): preserve staged roster invariants * test(config): align fixtures with explicit roster ownership * test(node-host): preserve optional plan typing * fix(config): preserve authored roster projections * test(config): keep raw roster fixtures explicit * test(config): normalize rosters at runtime fixtures * fix(config): protect authored roster ownership * fix(agents): require explicit session ownership * fix(agents): enforce scoped roster ownership * fix(sessions): merge fixed-store agent partitions * fix(agents): harden roster ownership boundaries * fix(config): reject ambiguous roster projections * fix(sessions): preserve persisted store ownership * fix(sessions): keep collision diagnostics additive * fix(security): scan malformed roster workspaces * test(config): align snapshot fixtures after rebase * test(agents): use explicit roster fixtures * fix(config): harden roster diagnostic boundaries * fix(sessions): isolate fixed-store agent databases * test(agents): type malformed default markers * refactor(sessions): extract store collision resolution * test(system-agent): split oversized setup coverage * style(system-agent): format split setup suite * fix(sessions): preserve promoted store ownership * fix(sessions): derive scoped owner before target * fix(sessions): preserve explicit sqlite ownership * fix(agents): restore roster compatibility across CI * fix(agents): enforce roster-owned runtime boundaries * fix(agents): satisfy default lookup lint * test(sessions): split known-owner coverage * fix(state): satisfy path identity lint * fix(agents): preserve malformed roster safety boundaries * fix(agents): restore roster compatibility at runtime boundaries * fix(config): satisfy roster boundary type checks * fix(agents): preserve roster ownership across runtime probes Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests. * fix(agents): satisfy session list owner lint * fix(agents): preserve roster-owned runtime boundaries Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract. * fix(sessions): preserve multiply owned exact stores * fix(sessions): restore runtime owner boundaries Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix. * test(ci): align owner-scoped fixtures These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows. * fix(agents): close final roster ownership gaps
This commit is contained in:
committed by
GitHub
parent
5f63f744ea
commit
82d1a03f25
@@ -84,8 +84,8 @@ a5f59c9acbcaa3f82247bf806eb5ba08032373fb853719f0ec9457690f16fc70 module/media-m
|
||||
c5e3eb1a584f4b8126d9d6c177a840ec9103671e8d1242634ee67db9b5b9e573 module/media-understanding
|
||||
c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-understanding-runtime
|
||||
bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting-runtime
|
||||
d09ddb38c9d20a41fcf4cedaf6a2c28eb5333a9fba2287e34b458fa5caba751c module/memory-core-host-engine-foundation
|
||||
646773d8282a2ac6a89101685c406200935ddb3459a2830e820da132fa433b3c module/memory-host-core
|
||||
6b396a29c4efcd562accbd22dfa474f7815ce039cb8314a4771c2e37b5431ab4 module/memory-core-host-engine-foundation
|
||||
5d4d709d5ae573186459462fe5119bd253c13104554eb94aefbdab5f5c7ad46f module/memory-host-core
|
||||
87b7a3206346c0d4b294fb3a2395cbaabc3e73ff8b1b9ea925bc3aade3e52687 module/messaging-targets
|
||||
09f842a2787b87117d88ba6545aa0441ff8afdb85e2e66ae4d1838ebecb16148 module/model-session-runtime
|
||||
44655a08ce111a036c837b8d2e796cf0036941379ab317e5764c6c01e73b39f3 module/models-provider-runtime
|
||||
@@ -105,7 +105,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
|
||||
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
|
||||
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
|
||||
94356c388b1c5bc4ffb7fef6ece1e2fc5f6c9f0eaf0c280594b29230baf9af81 module/reply-runtime
|
||||
d78db621b8f4f0cc679cad2d5d21b6c95b5418c58611dd347cdf09049ed124f6 module/routing
|
||||
536341e301631a14ac67bd7e8d10d2ba770ff91c5f7a2c5dc8dd9dfd1c1c7ec4 module/routing
|
||||
ff6cca86f54f94f238205f5b122af36666314e0a380f3ec7f0ccb9ed9208df31 module/run-command
|
||||
53b0295cec105696a1664c5c7f5576a7b55d197eb95dcd9185486f010bd53750 module/runtime
|
||||
fcf103d18c1d64ffa9719f1d5a1ca349df3a6125d00c9d54a1b76c195ba8691b module/runtime-config-snapshot
|
||||
@@ -117,7 +117,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime
|
||||
17a6a199714ba8308e62928c0491bcf9fd214c923c3879aa99a047a30138253d module/secret-ref-runtime
|
||||
596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime
|
||||
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
|
||||
82f1f06f875097ee894348e74346fde79325c8fee7106b51438aaec1f539cbff module/session-store-runtime
|
||||
32fb6d253abf22440bc76c7a68d1f35fc0ef369b0ad738aedba9c3054a76e48e module/session-store-runtime
|
||||
7cad408673562b0ff33f60071698bea2c39d02c64f4f235851c5c2ceb946910d module/setup
|
||||
fbcb853789db5a1ad5d81e8094ed7f5bcea657ac2281e7887a19c1d6f1194571 module/setup-runtime
|
||||
cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools
|
||||
|
||||
@@ -810,6 +810,7 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
|
||||
agents: {
|
||||
entries: {
|
||||
codex: {
|
||||
default: true,
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
|
||||
@@ -44,6 +44,7 @@ Make display-name pings work even when WhatsApp strips the visual `@` from the t
|
||||
agents: {
|
||||
entries: {
|
||||
main: {
|
||||
default: true,
|
||||
groupChat: {
|
||||
mentionPatterns: ["@?openclaw", "\\+?15555550123"],
|
||||
},
|
||||
|
||||
@@ -346,6 +346,7 @@ Each fact defaults to enabled when the channel produces it. Set the correspondin
|
||||
agents: {
|
||||
entries: {
|
||||
main: {
|
||||
default: true,
|
||||
groupChat: {
|
||||
mentionPatterns: ["@openclaw", "openclaw", "\\+15555550123"],
|
||||
historyLimit: 50,
|
||||
|
||||
@@ -147,8 +147,15 @@ function resolveLegacySanitizedSessionKey(
|
||||
return matches.length === 1 && match ? match : null;
|
||||
}
|
||||
|
||||
function listAgentIds(config: { agents?: { list?: Array<{ id?: unknown }> } }): string[] {
|
||||
function listAgentIds(config: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>(["main"]);
|
||||
if (isRecord(config.agents?.entries)) {
|
||||
for (const agentId of Object.keys(config.agents.entries)) {
|
||||
if (agentId.trim()) {
|
||||
ids.add(agentId.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
if (typeof agent.id === "string" && agent.id.trim()) {
|
||||
ids.add(agent.id.trim());
|
||||
@@ -162,7 +169,6 @@ function listCandidateStorePaths(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const paths = new Set<string>();
|
||||
paths.add(resolveStorePath(params.config.session?.store, { env: params.env }));
|
||||
for (const agentId of listAgentIds(params.config)) {
|
||||
paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Telegram reply-chain cache and prompt-context projection.
|
||||
import type { Message } from "grammy/types";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
|
||||
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking";
|
||||
@@ -55,7 +56,11 @@ export function createTelegramMessageContextRuntime({
|
||||
"cfg" | "accountId" | "opts" | "telegramCfg" | "telegramDeps"
|
||||
>) {
|
||||
const messageCache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(telegramDeps.resolveStorePath(cfg.session?.store)),
|
||||
scope: resolveTelegramMessageCacheScope(
|
||||
telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||
agentId: cfg.agents ? resolveDefaultAgentId(cfg) : "main",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const resolvePromptSender = (
|
||||
node: TelegramCachedMessageNode,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { TelegramRuntime } from "./runtime.types.js";
|
||||
|
||||
const cfg = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
channels: { telegram: { botToken: "tok" } },
|
||||
session: { store: "/tmp/openclaw-telegram-topic-binding-test.json" },
|
||||
} as OpenClawConfig;
|
||||
@@ -66,7 +67,9 @@ async function recordMessage(params: {
|
||||
accountId?: string;
|
||||
}) {
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(resolveStorePath(cfg.session?.store)),
|
||||
scope: resolveTelegramMessageCacheScope(
|
||||
resolveStorePath(cfg.session?.store, { agentId: "main" }),
|
||||
),
|
||||
});
|
||||
await cache.record({
|
||||
accountId: params.accountId ?? "default",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Telegram provider-owned authorization for message mutations in forum topics.
|
||||
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-core";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type {
|
||||
ChannelMessageActionContext,
|
||||
ChannelThreadingToolContext,
|
||||
@@ -112,7 +113,11 @@ export async function resolveTelegramMessageMutationChatId(params: {
|
||||
}
|
||||
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)),
|
||||
scope: resolveTelegramMessageCacheScope(
|
||||
resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main",
|
||||
}),
|
||||
),
|
||||
});
|
||||
const cached = await cache.get({
|
||||
accountId: selectedAccountId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Telegram plugin module implements outbound message context behavior.
|
||||
import type { Message } from "grammy/types";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
@@ -151,7 +152,11 @@ export async function recordOutboundMessageForPromptContext(params: {
|
||||
...(messageThreadId !== undefined ? { messageThreadId } : {}),
|
||||
});
|
||||
const cache = createTelegramMessageCache({
|
||||
scope: resolveTelegramMessageCacheScope(resolveStorePath(params.cfg.session?.store)),
|
||||
scope: resolveTelegramMessageCacheScope(
|
||||
resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.cfg.agents ? resolveDefaultAgentId(params.cfg) : "main",
|
||||
}),
|
||||
),
|
||||
});
|
||||
await cache.record({
|
||||
accountId: params.account.accountId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Telegram plugin module implements sent message cache behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -31,6 +32,8 @@ type SentMessageState = {
|
||||
bucketsByScope: Map<string, SentMessageBucket>;
|
||||
};
|
||||
|
||||
type SentMessageConfig = Pick<OpenClawConfig, "agents" | "session">;
|
||||
|
||||
function getSentMessageState(): SentMessageState {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = globalStore[TELEGRAM_SENT_MESSAGES_STATE_KEY] as SentMessageState | undefined;
|
||||
@@ -48,15 +51,30 @@ function createSentMessageStore(): SentMessageStore {
|
||||
return new Map<string, Map<string, number>>();
|
||||
}
|
||||
|
||||
function resolveSentMessageStorePath(cfg?: Pick<OpenClawConfig, "session">): string {
|
||||
return `${resolveStorePath(cfg?.session?.store)}.telegram-sent-messages.json`;
|
||||
function resolveSentMessageAgentId(cfg?: SentMessageConfig, agentId?: string): string {
|
||||
return agentId?.trim() || (cfg?.agents ? resolveDefaultAgentId(cfg as OpenClawConfig) : "main");
|
||||
}
|
||||
|
||||
function resolveSentMessageScopeKey(cfg?: Pick<OpenClawConfig, "session">): string {
|
||||
const storePath = resolveStorePath(cfg?.session?.store);
|
||||
function resolveSentMessageStorePath(cfg?: SentMessageConfig, agentId?: string): string {
|
||||
return `${resolveStorePath(cfg?.session?.store, {
|
||||
agentId: resolveSentMessageAgentId(cfg, agentId),
|
||||
})}.telegram-sent-messages.json`;
|
||||
}
|
||||
|
||||
function sentMessageScopeKeyForStorePath(storePath: string): string {
|
||||
return createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
function resolveSentMessageScopeKey(cfg?: SentMessageConfig, agentId?: string): string {
|
||||
// This 24-hour cache follows the current agent owner. Do not revive a prior owner's
|
||||
// transient bucket when the configured default changes.
|
||||
return sentMessageScopeKeyForStorePath(
|
||||
resolveStorePath(cfg?.session?.store, {
|
||||
agentId: resolveSentMessageAgentId(cfg, agentId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function sentMessageEntryKey(scopeKey: string, chatId: string, messageId: string): string {
|
||||
return createHash("sha256")
|
||||
.update(`${scopeKey}\0${chatId}\0${messageId}`, "utf8")
|
||||
@@ -142,7 +160,7 @@ function readPersistedSentMessages(scopeKey: string): SentMessageStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
function getSentMessageBucket(cfg?: Pick<OpenClawConfig, "session">): SentMessageBucket {
|
||||
function getSentMessageBucket(cfg?: SentMessageConfig): SentMessageBucket {
|
||||
const state = getSentMessageState();
|
||||
const scopeKey = resolveSentMessageScopeKey(cfg);
|
||||
const existing = state.bucketsByScope.get(scopeKey);
|
||||
@@ -157,7 +175,7 @@ function getSentMessageBucket(cfg?: Pick<OpenClawConfig, "session">): SentMessag
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function getSentMessages(cfg?: Pick<OpenClawConfig, "session">): SentMessageStore {
|
||||
function getSentMessages(cfg?: SentMessageConfig): SentMessageStore {
|
||||
return getSentMessageBucket(cfg).store;
|
||||
}
|
||||
|
||||
@@ -177,7 +195,7 @@ function persistSentMessage(
|
||||
export function recordSentMessage(
|
||||
chatId: number | string,
|
||||
messageId: number,
|
||||
cfg?: Pick<OpenClawConfig, "session">,
|
||||
cfg?: SentMessageConfig,
|
||||
): void {
|
||||
const scopeKey = String(chatId);
|
||||
const idKey = String(messageId);
|
||||
@@ -201,7 +219,7 @@ export function recordSentMessage(
|
||||
export function wasSentByBot(
|
||||
chatId: number | string,
|
||||
messageId: number,
|
||||
cfg?: Pick<OpenClawConfig, "session">,
|
||||
cfg?: SentMessageConfig,
|
||||
): boolean {
|
||||
const scopeKey = String(chatId);
|
||||
const idKey = String(messageId);
|
||||
@@ -215,11 +233,15 @@ export function wasSentByBot(
|
||||
}
|
||||
|
||||
export function listTelegramLegacySentMessageCacheEntries(params: {
|
||||
cfg?: Pick<OpenClawConfig, "session">;
|
||||
cfg?: SentMessageConfig;
|
||||
agentId?: string;
|
||||
persistedPath?: string;
|
||||
targetStorePath?: string;
|
||||
}): Array<{ key: string; value: PersistedSentMessage; ttlMs?: number; timestamp?: number }> {
|
||||
const scopeKey = resolveSentMessageScopeKey(params.cfg);
|
||||
const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg);
|
||||
const scopeKey = params.targetStorePath
|
||||
? sentMessageScopeKeyForStorePath(params.targetStorePath)
|
||||
: resolveSentMessageScopeKey(params.cfg, params.agentId);
|
||||
const filePath = params.persistedPath ?? resolveSentMessageStorePath(params.cfg, params.agentId);
|
||||
const legacy = fs.existsSync(filePath)
|
||||
? readLegacySentMessages(filePath)
|
||||
: createSentMessageStore();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Telegram tests cover state migrations plugin behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -128,7 +129,7 @@ describe("telegram state migrations", () => {
|
||||
it("detects legacy message-cache import for the runtime sidecar path", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
||||
const storePath = resolveStorePath(undefined, { env });
|
||||
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
||||
const persistedPath = resolveTelegramMessageCachePath(storePath);
|
||||
try {
|
||||
await mkdir(path.dirname(persistedPath), { recursive: true });
|
||||
@@ -339,7 +340,7 @@ describe("telegram state migrations", () => {
|
||||
it("detects remaining Telegram JSON sidecars for plugin-state import", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
||||
const storePath = resolveStorePath(undefined, { env });
|
||||
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
||||
const now = Date.now();
|
||||
const updateOffsetPath = path.join(dir, "telegram", "update-offset-ops.json");
|
||||
const stickerCachePath = path.join(dir, "telegram", "sticker-cache.json");
|
||||
@@ -483,7 +484,7 @@ describe("telegram state migrations", () => {
|
||||
vi.setSystemTime(new Date("2026-07-15T12:00:00.000Z"));
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
||||
const storePath = resolveStorePath(undefined, { env });
|
||||
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
||||
const sentMessagePath = `${storePath}.telegram-sent-messages.json`;
|
||||
const dispatchPath = resolveTelegramMessageDispatchLegacyPath({
|
||||
storePath,
|
||||
@@ -711,7 +712,7 @@ describe("telegram state migrations", () => {
|
||||
it("imports legacy session-store sidecars into the current runtime scope", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-state-migration-"));
|
||||
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
|
||||
const storePath = resolveStorePath(undefined, { env });
|
||||
const storePath = resolveStorePath(undefined, { env, agentId: "main" });
|
||||
const legacyStorePath = path.join(dir, "sessions", "sessions.json");
|
||||
const currentSentPath = `${storePath}.telegram-sent-messages.json`;
|
||||
const legacySentPath = `${legacyStorePath}.telegram-sent-messages.json`;
|
||||
@@ -771,9 +772,11 @@ describe("telegram state migrations", () => {
|
||||
|
||||
const stripTtl = (entries: Awaited<ReturnType<typeof currentSentPlan.readEntries>>) =>
|
||||
entries.map(({ ttlMs: _ttlMs, ...entry }) => entry);
|
||||
expect(stripTtl(await legacySentPlan.readEntries())).toStrictEqual(
|
||||
stripTtl(await currentSentPlan.readEntries()),
|
||||
);
|
||||
const currentSentEntries = stripTtl(await currentSentPlan.readEntries());
|
||||
expect(stripTtl(await legacySentPlan.readEntries())).toStrictEqual(currentSentEntries);
|
||||
expect(currentSentEntries[0]?.value).toMatchObject({
|
||||
scopeKey: createHash("sha256").update(storePath, "utf8").digest("hex").slice(0, 24),
|
||||
});
|
||||
const stripDispatchSourceKey = (
|
||||
entries: Awaited<ReturnType<typeof currentDispatchPlan.readEntries>>,
|
||||
) => entries.map(({ key: _key, ttlMs: _ttlMs, ...entry }) => entry);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Telegram plugin module implements state migrations behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
@@ -87,11 +88,26 @@ function resolveLegacySessionStorePath(params: {
|
||||
return path.join(resolveMigrationStateDir(params), "sessions", "sessions.json");
|
||||
}
|
||||
|
||||
function resolveAgentSessionStorePath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
agentId: string;
|
||||
}): string {
|
||||
return resolveStorePath(params.cfg.session?.store, {
|
||||
env: params.env,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMigrationStateDir(params: { env: NodeJS.ProcessEnv; stateDir?: string }): string {
|
||||
return (
|
||||
params.stateDir ??
|
||||
path.dirname(
|
||||
path.dirname(path.dirname(path.dirname(resolveStorePath(undefined, { env: params.env })))),
|
||||
path.dirname(
|
||||
path.dirname(
|
||||
path.dirname(resolveStorePath(undefined, { env: params.env, agentId: "main" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -309,12 +325,20 @@ function detectTelegramMessageCacheLegacyStateMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): ChannelLegacyStateMigrationPlan[] {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
|
||||
const storePath = resolveAgentSessionStorePath({
|
||||
...params,
|
||||
agentId: resolveDefaultAgentId(params.cfg),
|
||||
});
|
||||
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
|
||||
const runtimePersistedPath = resolveTelegramMessageCachePath(storePath);
|
||||
const legacyStorePath = resolveLegacySessionStorePath(params);
|
||||
const legacyPersistedPath = resolveTelegramMessageCachePath(legacyStorePath);
|
||||
const scopeKey = resolveTelegramMessageCachePersistentScopeKey(runtimePersistedPath);
|
||||
return uniqueStrings([runtimePersistedPath, legacyPersistedPath]).flatMap((persistedPath) => {
|
||||
return uniqueStrings([
|
||||
runtimePersistedPath,
|
||||
resolveTelegramMessageCachePath(legacyMainStorePath),
|
||||
legacyPersistedPath,
|
||||
]).flatMap((persistedPath) => {
|
||||
if (!fileExists(persistedPath)) {
|
||||
return [];
|
||||
}
|
||||
@@ -444,12 +468,16 @@ function detectTelegramSentMessageCacheLegacyStateMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): ChannelLegacyStateMigrationPlan[] {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
|
||||
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||
const storePath = resolveAgentSessionStorePath({ ...params, agentId: defaultAgentId });
|
||||
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
|
||||
const legacyStorePath = resolveLegacySessionStorePath(params);
|
||||
const sources = uniqueStrings([storePath, legacyStorePath]).map((sourceStorePath) => ({
|
||||
targetStorePath: storePath,
|
||||
sourcePath: `${sourceStorePath}.telegram-sent-messages.json`,
|
||||
}));
|
||||
const sources = uniqueStrings([storePath, legacyMainStorePath, legacyStorePath]).map(
|
||||
(sourceStorePath) => ({
|
||||
targetStorePath: storePath,
|
||||
sourcePath: `${sourceStorePath}.telegram-sent-messages.json`,
|
||||
}),
|
||||
);
|
||||
return sources.flatMap((source) => {
|
||||
if (!fileExists(source.sourcePath)) {
|
||||
return [];
|
||||
@@ -468,8 +496,10 @@ function detectTelegramSentMessageCacheLegacyStateMigration(params: {
|
||||
preview: `- Telegram sent-message cache: ${source.sourcePath} → plugin state (${TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE})`,
|
||||
readEntries: () =>
|
||||
listTelegramLegacySentMessageCacheEntries({
|
||||
cfg: { session: { store: source.targetStorePath } },
|
||||
cfg: params.cfg,
|
||||
agentId: defaultAgentId,
|
||||
persistedPath: source.sourcePath,
|
||||
targetStorePath: source.targetStorePath,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -512,7 +542,11 @@ function detectTelegramMessageDispatchLegacyStateMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): ChannelLegacyStateMigrationPlan[] {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
|
||||
const storePath = resolveAgentSessionStorePath({
|
||||
...params,
|
||||
agentId: resolveDefaultAgentId(params.cfg),
|
||||
});
|
||||
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
|
||||
const legacyStorePath = resolveLegacySessionStorePath(params);
|
||||
const env = params.stateDir ? { ...params.env, OPENCLAW_STATE_DIR: params.stateDir } : params.env;
|
||||
const namespace = resolvePersistentDedupePluginStateNamespace({
|
||||
@@ -520,12 +554,14 @@ function detectTelegramMessageDispatchLegacyStateMigration(params: {
|
||||
namespacePrefix: TELEGRAM_MESSAGE_DISPATCH_DEDUPE_NAMESPACE_PREFIX,
|
||||
});
|
||||
return listTelegramAccountIds(params.cfg).flatMap((accountId) => {
|
||||
const sources = uniqueStrings([storePath, legacyStorePath]).map((sourceStorePath) => ({
|
||||
sourcePath: resolveTelegramMessageDispatchLegacyPath({
|
||||
storePath: sourceStorePath,
|
||||
namespace: accountId,
|
||||
const sources = uniqueStrings([storePath, legacyMainStorePath, legacyStorePath]).map(
|
||||
(sourceStorePath) => ({
|
||||
sourcePath: resolveTelegramMessageDispatchLegacyPath({
|
||||
storePath: sourceStorePath,
|
||||
namespace: accountId,
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
);
|
||||
const jsonPlans: ChannelLegacyStateMigrationPlan[] = sources.flatMap((source) => {
|
||||
const sourcePath = source.sourcePath;
|
||||
if (!fileExists(sourcePath)) {
|
||||
@@ -612,7 +648,11 @@ function detectTelegramTopicNameCacheLegacyStateMigration(params: {
|
||||
});
|
||||
return topicNameCacheImportSource({ sourceStorePath: storePath });
|
||||
});
|
||||
const defaultStorePath = resolveStorePath(params.cfg.session?.store, { env: params.env });
|
||||
const defaultStorePath = resolveAgentSessionStorePath({
|
||||
...params,
|
||||
agentId: resolveDefaultAgentId(params.cfg),
|
||||
});
|
||||
const legacyMainStorePath = resolveAgentSessionStorePath({ ...params, agentId: "main" });
|
||||
const defaultAccountStorePath = resolveStorePath(params.cfg.session?.store, {
|
||||
env: params.env,
|
||||
agentId: resolveDefaultTelegramAccountId(params.cfg),
|
||||
@@ -622,6 +662,7 @@ function detectTelegramTopicNameCacheLegacyStateMigration(params: {
|
||||
[
|
||||
...accountSources,
|
||||
topicNameCacheImportSource({ sourceStorePath: defaultStorePath }),
|
||||
topicNameCacheImportSource({ sourceStorePath: legacyMainStorePath }),
|
||||
topicNameCacheImportSource({
|
||||
sourceStorePath: legacyStorePath,
|
||||
targetStorePath: defaultAccountStorePath,
|
||||
|
||||
@@ -64,6 +64,33 @@ describe("configured model refs", () => {
|
||||
).toEqual(["openai/gpt-5.5"]);
|
||||
});
|
||||
|
||||
it("preserves legacy list indices when collecting agent model refs", () => {
|
||||
expect(
|
||||
collectConfiguredModelRefs({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "10", model: "openai/gpt-5.6" },
|
||||
{ id: "2", utilityModel: "anthropic/claude-sonnet-4-6" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ path: "agents.list.0.model", value: "openai/gpt-5.6" },
|
||||
{ path: "agents.list.1.utilityModel", value: "anthropic/claude-sonnet-4-6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a shadowed legacy list when keyed entries are authoritative", () => {
|
||||
expect(
|
||||
collectConfiguredModelRefs({
|
||||
agents: {
|
||||
entries: { ops: { model: "openai/gpt-5.6" } },
|
||||
list: [{ id: "stale", model: "anthropic/claude-opus-4-8" }],
|
||||
},
|
||||
}),
|
||||
).toEqual([{ path: "agents.entries.ops.model", value: "openai/gpt-5.6" }]);
|
||||
});
|
||||
|
||||
it("ignores array-shaped malformed records", () => {
|
||||
expect(
|
||||
collectConfiguredModelRefs({
|
||||
|
||||
@@ -86,6 +86,10 @@ export function collectConfiguredModelRefs(
|
||||
for (const [agentId, entry] of Object.entries(agents.entries)) {
|
||||
collectFromAgent(`agents.entries.${agentId}`, entry);
|
||||
}
|
||||
} else if (Array.isArray(agents.list)) {
|
||||
for (const [index, entry] of agents.list.entries()) {
|
||||
collectFromAgent(`agents.list.${index}`, entry);
|
||||
}
|
||||
}
|
||||
if (options.includeChannelModelOverrides !== false) {
|
||||
const channels = isRecord(root.channels) ? root.channels : {};
|
||||
|
||||
@@ -173,7 +173,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
// +6: canonical session delivery normalization, access, and projection helpers.
|
||||
// +5: focused media-local-roots helpers and typed hook media contracts.
|
||||
// +1: model-independent agent-harness preflight failure contract.
|
||||
4717,
|
||||
// +1: typed owner-required error for session store path resolution.
|
||||
4718,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { safeParseJson } from "@openclaw/normalization-core";
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { Insertable, Selectable } from "kysely";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import {
|
||||
@@ -82,14 +83,11 @@ export function resolveSessionStorePathForAcp(params: {
|
||||
}): { cfg: OpenClawConfig; agentId?: string; storePath: string } {
|
||||
const cfg = params.cfg ?? getRuntimeConfig();
|
||||
const parsed = parseAgentSessionKey(params.sessionKey);
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: parsed?.agentId,
|
||||
env: params.env,
|
||||
});
|
||||
const agentId = parsed?.agentId ?? resolveDefaultAgentId(cfg);
|
||||
return {
|
||||
cfg,
|
||||
...(parsed?.agentId ? { agentId: parsed.agentId } : {}),
|
||||
storePath,
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId, env: params.env }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
type AcpSpawnParentRelayHandle,
|
||||
startAcpSpawnParentStreamRelay,
|
||||
} from "./acp-spawn-parent-stream.js";
|
||||
import { listAgentEntries } from "./agent-scope-config.js";
|
||||
import { listAgentIds, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import {
|
||||
findAcpUnsupportedInheritedToolAllow,
|
||||
@@ -349,7 +350,7 @@ function isHeartbeatEnabledForSessionAgent(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
const agentEntries = Array.isArray(params.cfg.agents?.list) ? params.cfg.agents.list : [];
|
||||
const agentEntries = listAgentEntries(params.cfg);
|
||||
const hasExplicitHeartbeatAgents = agentEntries.some((entry) => Boolean(entry?.heartbeat));
|
||||
const enabledByPolicy = hasExplicitHeartbeatAgents
|
||||
? agentEntries.some(
|
||||
@@ -435,7 +436,7 @@ function resolveTargetAcpAgentId(params: {
|
||||
}): { ok: true; agentId: string; configAgentId?: string } | { ok: false; error: string } {
|
||||
const requested = normalizeOptionalAgentId(params.requestedAgentId);
|
||||
if (requested) {
|
||||
const configuredAgent = params.cfg.agents?.list?.find(
|
||||
const configuredAgent = listAgentEntries(params.cfg).find(
|
||||
(agent) => normalizeOptionalAgentId(agent.id) === requested,
|
||||
);
|
||||
if (configuredAgent?.runtime?.type === "acp") {
|
||||
@@ -485,7 +486,7 @@ function isExplicitlyAllowedAcpAgent(cfg: OpenClawConfig, agentId: string): bool
|
||||
|
||||
function resolveConfiguredAcpSubagentTargetIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>(listAgentIds(cfg));
|
||||
for (const agent of cfg.agents?.list ?? []) {
|
||||
for (const agent of listAgentEntries(cfg)) {
|
||||
if (agent.runtime?.type !== "acp") {
|
||||
continue;
|
||||
}
|
||||
@@ -1246,7 +1247,7 @@ export async function spawnAcpDirect(
|
||||
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
|
||||
const childIdem = crypto.randomUUID();
|
||||
const parentAgentId = parentSessionKey
|
||||
? resolveAgentIdFromSessionKey(parentSessionKey)
|
||||
? resolveAgentIdFromSessionKey(parentSessionKey, resolveDefaultAgentId(cfg))
|
||||
: undefined;
|
||||
// Resolve parent session delivery context so system events route to the
|
||||
// correct thread/topic instead of falling back to the main DM.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mutateConfigFileWithRetry } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { createAgent } from "./agent-create.js";
|
||||
@@ -38,3 +40,80 @@ it("keeps a fresh named workspace pending through the first run setup", async ()
|
||||
await state.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe("agent roster persistence", () => {
|
||||
async function addWorkerToConfig(config: unknown): Promise<OpenClawConfig> {
|
||||
const state = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
scenario: "empty",
|
||||
label: "agent-roster-write",
|
||||
});
|
||||
try {
|
||||
await state.writeConfig(config);
|
||||
const result = await createAgent({ name: "Worker", workspace: state.path("worker") });
|
||||
expect(result).toMatchObject({ status: "created", agentId: "worker" });
|
||||
return JSON.parse(await fs.readFile(state.configPath, "utf8")) as OpenClawConfig;
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await state.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
it("writes injected main and a new worker as one complete keyed roster", async () => {
|
||||
const persisted = await addWorkerToConfig({ gateway: { mode: "local" } });
|
||||
|
||||
expect(persisted.agents?.entries).toMatchObject({
|
||||
main: { default: true },
|
||||
worker: { workspace: expect.any(String) },
|
||||
});
|
||||
expect(
|
||||
Object.values(persisted.agents?.entries ?? {}).filter((entry) => entry.default === true),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("replaces a legacy list with the complete keyed roster", async () => {
|
||||
const persisted = await addWorkerToConfig({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", workspace: "/srv/ops" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(persisted.agents).not.toHaveProperty("list");
|
||||
expect(persisted.agents?.entries).toMatchObject({
|
||||
main: { default: true },
|
||||
ops: { workspace: "/srv/ops" },
|
||||
worker: { workspace: expect.any(String) },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a legacy list byte-for-byte during a non-roster mutation", async () => {
|
||||
const state = await createOpenClawTestState({
|
||||
layout: "state-only",
|
||||
scenario: "empty",
|
||||
label: "legacy-roster-non-roster-write",
|
||||
});
|
||||
const list = [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", workspace: "/srv/ops" },
|
||||
];
|
||||
try {
|
||||
await state.writeConfig({ agents: { list }, gateway: { port: 18789 } });
|
||||
await mutateConfigFileWithRetry({
|
||||
mutate: (config) => {
|
||||
config.gateway = { ...config.gateway, port: 19001 };
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(state.configPath, "utf8")) as OpenClawConfig;
|
||||
expect(JSON.stringify(persisted.agents?.list)).toBe(JSON.stringify(list));
|
||||
expect(persisted.agents).not.toHaveProperty("entries");
|
||||
expect(persisted.gateway?.port).toBe(19001);
|
||||
} finally {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await state.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ import { createAgent } from "./agent-create.js";
|
||||
describe("createAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.config = {};
|
||||
mocks.config = { agents: { list: [{ id: "main", default: true }] } };
|
||||
mocks.persisted = {};
|
||||
mocks.readAgentDeletionJournal.mockReturnValue(undefined);
|
||||
mocks.claimCompletedAgentDeletion.mockReturnValue(true);
|
||||
@@ -93,9 +93,11 @@ describe("createAgent", () => {
|
||||
async ({
|
||||
transform,
|
||||
}: {
|
||||
transform: (config: Record<string, unknown>) => Promise<unknown>;
|
||||
transform: (config: Record<string, unknown>, context: unknown) => Promise<unknown>;
|
||||
}) => {
|
||||
const transformed = (await transform(structuredClone(mocks.config))) as {
|
||||
const transformed = (await transform(structuredClone(mocks.config), {
|
||||
snapshot: { exists: false },
|
||||
})) as {
|
||||
nextConfig: Record<string, unknown>;
|
||||
result: unknown;
|
||||
};
|
||||
@@ -111,6 +113,10 @@ describe("createAgent", () => {
|
||||
status: "error",
|
||||
reason: "invalid-name",
|
||||
});
|
||||
await expect(createAgent({ name: "###" })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
reason: "invalid-name",
|
||||
});
|
||||
for (const name of ["main", "OpenClaw", "crestodian"]) {
|
||||
await expect(createAgent({ name })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
@@ -132,8 +138,208 @@ describe("createAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a complete staged entry", async () => {
|
||||
const result = await createAgent({
|
||||
entry: {
|
||||
id: "researcher",
|
||||
name: "Researcher",
|
||||
workspace: "/tmp/staged-work",
|
||||
agentDir: "/tmp/staged-agent",
|
||||
model: "openai/gpt-5.5",
|
||||
identity: { name: "Researcher", emoji: "🔎" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "created",
|
||||
agentId: "researcher",
|
||||
workspace: "/tmp/staged-work",
|
||||
agentDir: "/tmp/staged-agent",
|
||||
});
|
||||
expect(mocks.persisted).toMatchObject({
|
||||
agents: {
|
||||
entries: {
|
||||
researcher: expect.objectContaining({ model: "openai/gpt-5.5" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the first staged roster entry as the default", async () => {
|
||||
mocks.config = { agents: { list: [] } };
|
||||
|
||||
await createAgent({
|
||||
entry: { id: "researcher", name: "Researcher", default: false },
|
||||
});
|
||||
|
||||
expect(mocks.persisted).toMatchObject({
|
||||
agents: { entries: { researcher: expect.objectContaining({ default: true }) } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "staged model only",
|
||||
entryModel: "openai/staged",
|
||||
paramsModel: undefined,
|
||||
},
|
||||
{
|
||||
label: "staged model over explicit parameter",
|
||||
entryModel: "openai/staged",
|
||||
paramsModel: "openai/parameter",
|
||||
},
|
||||
])(
|
||||
"preserves $label when workspace setup normalizes the path",
|
||||
async ({ entryModel, paramsModel }) => {
|
||||
mocks.ensureAgentWorkspace.mockResolvedValue({
|
||||
dir: "/normalized/work",
|
||||
bootstrapPending: true,
|
||||
});
|
||||
|
||||
await createAgent({
|
||||
entry: {
|
||||
id: "researcher",
|
||||
name: "Researcher",
|
||||
workspace: "/staged/work",
|
||||
model: entryModel,
|
||||
},
|
||||
model: paramsModel,
|
||||
});
|
||||
|
||||
expect(mocks.persisted).toMatchObject({
|
||||
agents: {
|
||||
entries: {
|
||||
researcher: expect.objectContaining({
|
||||
model: entryModel,
|
||||
workspace: "/normalized/work",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves every legacy-list agent when staging a new entry", async () => {
|
||||
mocks.config = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true, name: "Main" },
|
||||
{ id: "ops", name: "Ops" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await createAgent({
|
||||
entry: { id: "researcher", name: "Researcher", model: "openai/gpt-5.5" },
|
||||
});
|
||||
|
||||
expect(mocks.persisted).toMatchObject({
|
||||
agents: {
|
||||
entries: {
|
||||
main: { default: true, name: "Main" },
|
||||
ops: { name: "Ops" },
|
||||
researcher: expect.objectContaining({ model: "openai/gpt-5.5" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect((mocks.persisted.agents as { list?: unknown }).list).toBeUndefined();
|
||||
});
|
||||
|
||||
it("provisions the injected main roster only through a bootstrap entry", async () => {
|
||||
await expect(
|
||||
createAgent({
|
||||
entry: {
|
||||
id: "main",
|
||||
name: "main",
|
||||
default: true,
|
||||
workspace: "/tmp/main-work",
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "existing", agentId: "main" });
|
||||
expect(mocks.ensureAgentWorkspace).toHaveBeenCalledOnce();
|
||||
expect(mocks.persisted).toMatchObject({
|
||||
agents: { entries: { main: expect.objectContaining({ workspace: "/tmp/main-work" }) } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite an already materialized main agent", async () => {
|
||||
mocks.config = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, name: "Existing", workspace: "/tmp/existing" }],
|
||||
},
|
||||
};
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/existing");
|
||||
|
||||
await expect(
|
||||
createAgent({
|
||||
entry: { id: "main", name: "Replacement", default: true, workspace: "/tmp/new" },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "existing",
|
||||
name: "Existing",
|
||||
workspace: "/tmp/existing",
|
||||
bootstrapPending: false,
|
||||
});
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not materialize a minimal main entry from a persisted snapshot", async () => {
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValueOnce("/tmp/persisted");
|
||||
mocks.transformConfigFileWithRetry.mockImplementationOnce(async ({ transform }) => {
|
||||
const transformed = await transform(structuredClone(mocks.config), {
|
||||
snapshot: { exists: true },
|
||||
});
|
||||
return { ...transformed, result: transformed.result };
|
||||
});
|
||||
|
||||
await expect(
|
||||
createAgent({
|
||||
entry: { id: "main", default: true, workspace: "/tmp/replacement" },
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "existing", workspace: "/tmp/persisted" });
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a default marker when a roster already exists", async () => {
|
||||
const before = structuredClone(mocks.config);
|
||||
|
||||
await expect(
|
||||
createAgent({
|
||||
entry: { id: "researcher", name: "Researcher", default: true },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
reason: "default-conflict",
|
||||
message: expect.stringContaining("Reassign the default separately"),
|
||||
});
|
||||
expect(mocks.config).toEqual(before);
|
||||
expect(mocks.persisted).toEqual({});
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a concurrent non-main roster during main bootstrap", async () => {
|
||||
const transformConfig = vi.fn(async ({ transform }) =>
|
||||
transform({ agents: { list: [{ id: "main" }, { id: "ops", default: true }] } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
createAgent({
|
||||
entry: { id: "main", default: true, workspace: "/tmp/main" },
|
||||
transformConfig,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "error",
|
||||
reason: "default-conflict",
|
||||
message: expect.stringContaining("Reassign the default separately"),
|
||||
});
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects skipBootstrap from the current config", async () => {
|
||||
mocks.config = { agents: { defaults: { skipBootstrap: true } } };
|
||||
mocks.config = {
|
||||
agents: { defaults: { skipBootstrap: true }, list: [{ id: "main", default: true }] },
|
||||
};
|
||||
|
||||
await createAgent({ name: "researcher", workspace: "/tmp/work" });
|
||||
|
||||
@@ -249,7 +455,9 @@ describe("createAgent", () => {
|
||||
});
|
||||
|
||||
it("claims a recovered completed tombstone only once for an existing roster entry", async () => {
|
||||
mocks.config = { agents: { list: [{ id: "researcher" }] } };
|
||||
mocks.config = {
|
||||
agents: { list: [{ id: "main", default: true }, { id: "researcher" }] },
|
||||
};
|
||||
mocks.readAgentDeletionJournal.mockReturnValue({
|
||||
operationId: "delete-1",
|
||||
cleanupCompleted: true,
|
||||
@@ -279,7 +487,9 @@ describe("createAgent", () => {
|
||||
});
|
||||
|
||||
it("rejects a concurrent duplicate from the mutation snapshot", async () => {
|
||||
mocks.config = { agents: { list: [{ id: "researcher" }] } };
|
||||
mocks.config = {
|
||||
agents: { list: [{ id: "main", default: true }, { id: "researcher" }] },
|
||||
};
|
||||
|
||||
await expect(createAgent({ name: "researcher" })).resolves.toMatchObject({
|
||||
status: "error",
|
||||
@@ -295,7 +505,7 @@ describe("createAgent", () => {
|
||||
});
|
||||
const transformConfig = vi.fn(async ({ maxAttempts, transform }) => {
|
||||
expect(maxAttempts).toBe(1);
|
||||
return await transform({ agents: { list: [] } });
|
||||
return await transform({ agents: { list: [{ id: "main", default: true }] } });
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
||||
+152
-32
@@ -8,12 +8,15 @@ import {
|
||||
} from "../commands/agents.config.js";
|
||||
import { transformConfigFileWithRetry, withConfigMutationExclusive } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { FsSafeError, root } from "../infra/fs-safe.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { readAgentDeletionJournal } from "../state/agent-deletion-journal.js";
|
||||
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { claimCompletedAgentDeletion } from "./agent-lifecycle-registry.js";
|
||||
import { toAgentEntriesRecord } from "./agent-scope-config.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import {
|
||||
createAgentIdentityConfig,
|
||||
@@ -22,9 +25,11 @@ import {
|
||||
} from "./identity-file.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME, ensureAgentWorkspace } from "./workspace.js";
|
||||
|
||||
const RESERVED_BOOTSTRAP_AGENT_ID = "main";
|
||||
|
||||
type CreateAgentResult =
|
||||
| {
|
||||
status: "created";
|
||||
status: "created" | "existing";
|
||||
agentId: string;
|
||||
name: string;
|
||||
workspace: string;
|
||||
@@ -38,6 +43,7 @@ type CreateAgentResult =
|
||||
reason:
|
||||
| "invalid-name"
|
||||
| "reserved-id"
|
||||
| "default-conflict"
|
||||
| "already-exists"
|
||||
| "deletion-pending"
|
||||
| "invalid-bindings"
|
||||
@@ -47,19 +53,25 @@ type CreateAgentResult =
|
||||
};
|
||||
|
||||
type CreateError = Extract<CreateAgentResult, { status: "error" }>;
|
||||
type AgentEntryConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["entries"]>[string];
|
||||
type CreateAgentEntry = AgentEntryConfig & { id: string };
|
||||
|
||||
type CreateAgentParams = {
|
||||
name: string;
|
||||
name?: string;
|
||||
entry?: CreateAgentEntry;
|
||||
workspace?: string;
|
||||
model?: string;
|
||||
emoji?: unknown;
|
||||
avatar?: unknown;
|
||||
agentDir?: string;
|
||||
skipBootstrap?: boolean;
|
||||
skipOptionalBootstrapFiles?: OptionalBootstrapFileName[];
|
||||
bindingSpecs?: string[];
|
||||
transformConfig?: typeof transformConfigFileWithRetry;
|
||||
};
|
||||
|
||||
class DuplicateAgentError extends Error {}
|
||||
class DefaultAgentConflictError extends Error {}
|
||||
class InvalidAgentBindingsError extends Error {}
|
||||
|
||||
function createError(
|
||||
@@ -70,6 +82,19 @@ function createError(
|
||||
return { status: "error", reason, message, ...(agentId ? { agentId } : {}) };
|
||||
}
|
||||
|
||||
/** True when raw user input contains a character that can survive agent-id normalization. */
|
||||
function hasValidRawAgentIdCharacters(value: string): boolean {
|
||||
return /[a-z0-9]/iu.test(value);
|
||||
}
|
||||
|
||||
function isInjectedBootstrapMainEntry(entry: CreateAgentEntry | undefined): boolean {
|
||||
return (
|
||||
entry?.id === RESERVED_BOOTSTRAP_AGENT_ID &&
|
||||
entry.default === true &&
|
||||
Object.keys(entry).every((key) => key === "id" || key === "default")
|
||||
);
|
||||
}
|
||||
|
||||
async function writeIdentityFile(params: {
|
||||
workspaceDir: string;
|
||||
identity: NonNullable<ReturnType<typeof createAgentIdentityConfig>>;
|
||||
@@ -92,27 +117,38 @@ async function writeIdentityFile(params: {
|
||||
}
|
||||
|
||||
export async function createAgent(params: CreateAgentParams): Promise<CreateAgentResult> {
|
||||
const rawName = params.name.trim();
|
||||
const rawName = (params.entry?.name?.trim() || params.entry?.id || params.name || "").trim();
|
||||
if (!rawName) {
|
||||
return createError("invalid-name", "agent name is required");
|
||||
}
|
||||
const agentId = normalizeAgentId(rawName);
|
||||
if (agentId === DEFAULT_AGENT_ID || isReservedSystemAgentId(agentId)) {
|
||||
const rawId = params.entry?.id ?? rawName;
|
||||
if (!hasValidRawAgentIdCharacters(rawId)) {
|
||||
return createError("invalid-name", `agent name "${rawName}" has no valid id characters`);
|
||||
}
|
||||
const agentId = normalizeAgentId(rawId);
|
||||
const isBootstrapMain = agentId === RESERVED_BOOTSTRAP_AGENT_ID && params.entry?.default === true;
|
||||
if (
|
||||
(!isBootstrapMain && agentId === RESERVED_BOOTSTRAP_AGENT_ID) ||
|
||||
isReservedSystemAgentId(agentId)
|
||||
) {
|
||||
return createError("reserved-id", `"${agentId}" is reserved`, agentId);
|
||||
}
|
||||
|
||||
const safeName = sanitizeAgentIdentityLine(rawName);
|
||||
const model = normalizeOptionalString(params.model);
|
||||
const identity = createAgentIdentityConfig({
|
||||
name: safeName,
|
||||
emoji: params.emoji,
|
||||
avatar: params.avatar,
|
||||
}) ?? { name: safeName };
|
||||
const explicitWorkspace = params.workspace?.trim()
|
||||
? resolveUserPath(params.workspace.trim())
|
||||
const identity = params.entry?.identity ??
|
||||
createAgentIdentityConfig({
|
||||
name: safeName,
|
||||
emoji: params.emoji,
|
||||
avatar: params.avatar,
|
||||
}) ?? { name: safeName };
|
||||
const requestedWorkspace = params.entry?.workspace ?? params.workspace;
|
||||
const explicitWorkspace = requestedWorkspace?.trim()
|
||||
? resolveUserPath(requestedWorkspace.trim())
|
||||
: undefined;
|
||||
const explicitAgentDir = params.agentDir?.trim()
|
||||
? resolveUserPath(params.agentDir.trim())
|
||||
const requestedAgentDir = params.entry?.agentDir ?? params.agentDir;
|
||||
const explicitAgentDir = requestedAgentDir?.trim()
|
||||
? resolveUserPath(requestedAgentDir.trim())
|
||||
: undefined;
|
||||
const transformConfig = params.transformConfig ?? transformConfigFileWithRetry;
|
||||
|
||||
@@ -139,22 +175,83 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
|
||||
const committed = await transformConfig<CreateAgentResult>({
|
||||
afterWrite: { mode: "auto" },
|
||||
maxAttempts: 1,
|
||||
transform: async (currentConfig) => {
|
||||
if (findAgentEntryIndex(listAgentEntries(currentConfig), agentId) >= 0) {
|
||||
transform: async (currentConfig, context) => {
|
||||
const currentEntries = listAgentEntries(currentConfig);
|
||||
const existingIndex = findAgentEntryIndex(currentEntries, agentId);
|
||||
const existingEntry = currentEntries[existingIndex];
|
||||
const currentDefaults = currentEntries.filter((entry) => entry.default === true);
|
||||
const stagedDefaultMatchesCurrent =
|
||||
existingEntry?.default === true && currentDefaults.length === 1;
|
||||
if (
|
||||
params.entry?.default === true &&
|
||||
currentEntries.length > 0 &&
|
||||
!stagedDefaultMatchesCurrent
|
||||
) {
|
||||
throw new DefaultAgentConflictError();
|
||||
}
|
||||
if (existingIndex >= 0 && !isBootstrapMain) {
|
||||
throw new DuplicateAgentError();
|
||||
}
|
||||
|
||||
if (
|
||||
existingIndex >= 0 &&
|
||||
isBootstrapMain &&
|
||||
(!isInjectedBootstrapMainEntry(existingEntry) || context.snapshot.exists)
|
||||
) {
|
||||
return {
|
||||
nextConfig: currentConfig,
|
||||
result: {
|
||||
status: "existing",
|
||||
agentId,
|
||||
name: existingEntry?.name ?? safeName,
|
||||
workspace: resolveAgentWorkspaceDir(currentConfig, agentId),
|
||||
agentDir: resolveAgentDir(currentConfig, agentId),
|
||||
bootstrapPending: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceDir =
|
||||
explicitWorkspace ?? resolveAgentWorkspaceDir(currentConfig, agentId);
|
||||
const agentDir = explicitAgentDir ?? resolveAgentDir(currentConfig, agentId);
|
||||
let nextConfig = applyAgentConfig(currentConfig, {
|
||||
agentId,
|
||||
name: safeName,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
model,
|
||||
identity,
|
||||
});
|
||||
const materializeInjectedMain =
|
||||
existingIndex >= 0 &&
|
||||
isBootstrapMain &&
|
||||
isInjectedBootstrapMainEntry(existingEntry) &&
|
||||
!context.snapshot.exists;
|
||||
let nextConfig =
|
||||
existingIndex < 0 || materializeInjectedMain
|
||||
? applyAgentConfig(currentConfig, {
|
||||
agentId,
|
||||
name: safeName,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
model,
|
||||
identity,
|
||||
})
|
||||
: currentConfig;
|
||||
if (params.entry) {
|
||||
const list = listAgentEntries(nextConfig);
|
||||
const index = findAgentEntryIndex(list, agentId);
|
||||
list[index] = {
|
||||
...list[index],
|
||||
...params.entry,
|
||||
id: agentId,
|
||||
name: safeName,
|
||||
workspace: workspaceDir,
|
||||
agentDir,
|
||||
identity,
|
||||
...(list.length === 1 ? { default: true } : {}),
|
||||
};
|
||||
const { list: _legacyList, ...agentsConfig } = nextConfig.agents ?? {};
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: {
|
||||
...agentsConfig,
|
||||
entries: toAgentEntriesRecord(list),
|
||||
},
|
||||
};
|
||||
}
|
||||
const bindingParse = parseBindingSpecs({
|
||||
agentId,
|
||||
specs: params.bindingSpecs,
|
||||
@@ -172,14 +269,30 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
|
||||
// finishes before the final entry becomes visible to readers or delete flows.
|
||||
const workspace = await ensureAgentWorkspace({
|
||||
dir: workspaceDir,
|
||||
ensureBootstrapFiles: !nextConfig.agents?.defaults?.skipBootstrap,
|
||||
skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
|
||||
ensureBootstrapFiles:
|
||||
params.skipBootstrap === undefined
|
||||
? !nextConfig.agents?.defaults?.skipBootstrap
|
||||
: !params.skipBootstrap,
|
||||
skipOptionalBootstrapFiles:
|
||||
params.skipOptionalBootstrapFiles ??
|
||||
nextConfig.agents?.defaults?.skipOptionalBootstrapFiles,
|
||||
});
|
||||
if (workspace.dir !== workspaceDir) {
|
||||
nextConfig = applyAgentConfig(nextConfig, {
|
||||
agentId,
|
||||
workspace: workspace.dir,
|
||||
});
|
||||
const entries = listAgentEntries(nextConfig);
|
||||
const entryIndex = findAgentEntryIndex(entries, agentId);
|
||||
const currentEntry = entries[entryIndex];
|
||||
if (entryIndex >= 0 && currentEntry) {
|
||||
entries[entryIndex] = {
|
||||
...currentEntry,
|
||||
id: agentId,
|
||||
workspace: workspace.dir,
|
||||
};
|
||||
const { list: _legacyList, ...agentsConfig } = nextConfig.agents ?? {};
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agents: { ...agentsConfig, entries: toAgentEntriesRecord(entries) },
|
||||
};
|
||||
}
|
||||
}
|
||||
await fs.mkdir(resolveSessionTranscriptsDirForAgent(agentId), { recursive: true });
|
||||
// A creation-time name is config, not proof that the fresh workspace hatched.
|
||||
@@ -191,7 +304,7 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
|
||||
return {
|
||||
nextConfig,
|
||||
result: {
|
||||
status: "created",
|
||||
status: existingIndex >= 0 ? "existing" : "created",
|
||||
agentId,
|
||||
name: safeName,
|
||||
workspace: workspace.dir,
|
||||
@@ -217,6 +330,13 @@ export async function createAgent(params: CreateAgentParams): Promise<CreateAgen
|
||||
if (error instanceof DuplicateAgentError) {
|
||||
return createError("already-exists", `agent "${agentId}" already exists`, agentId);
|
||||
}
|
||||
if (error instanceof DefaultAgentConflictError) {
|
||||
return createError(
|
||||
"default-conflict",
|
||||
`Cannot create agent "${agentId}" with default=true while a roster already exists. Reassign the default separately.`,
|
||||
agentId,
|
||||
);
|
||||
}
|
||||
if (error instanceof InvalidAgentBindingsError) {
|
||||
return createError("invalid-bindings", error.message, agentId);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Agent runtime id normalization and retired runtime-selection compatibility helpers. */
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
|
||||
export type EmbeddedAgentRuntime = "openclaw" | "auto" | (string & {});
|
||||
|
||||
@@ -40,10 +41,10 @@ export function resolveAgentScopedRuntimeOverride(params: {
|
||||
agentId?: string;
|
||||
}): EmbeddedAgentRuntime | undefined {
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const agentRuntime = agentId
|
||||
? params.config?.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)
|
||||
?.agentRuntime?.id
|
||||
: undefined;
|
||||
const agentRuntime =
|
||||
agentId && params.config
|
||||
? resolveAgentConfig(params.config, agentId)?.agentRuntime?.id
|
||||
: undefined;
|
||||
return normalizeOptionalAgentRuntimeId(
|
||||
agentRuntime ?? params.config?.agents?.defaults?.agentRuntime?.id,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,79 @@
|
||||
// Agent scope tests cover which per-agent fields may flatten into runtime defaults.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
import {
|
||||
listAgentEntriesWithSource,
|
||||
listAgentIds,
|
||||
resolveAgentConfig,
|
||||
resolveDefaultAgentId,
|
||||
tryResolveDefaultAgentId,
|
||||
} from "./agent-scope-config.js";
|
||||
|
||||
vi.unmock("./agent-scope-config.js");
|
||||
|
||||
describe("agent roster resolution", () => {
|
||||
it("preserves the Plugin SDK fallback only when the roster property is absent", () => {
|
||||
expect(listAgentIds({})).toEqual(["main"]);
|
||||
expect(listAgentIds({ agents: { entries: {} } })).toEqual([]);
|
||||
expect(resolveDefaultAgentId({})).toBe("main");
|
||||
expect(resolveDefaultAgentId({ agents: { list: undefined } })).toBe("main");
|
||||
expect(resolveDefaultAgentId({ agents: { defaults: { workspace: "/srv/main" } } })).toBe(
|
||||
"main",
|
||||
);
|
||||
expect(() => resolveDefaultAgentId({ agents: { entries: {} } })).toThrow(
|
||||
"No agents configured",
|
||||
);
|
||||
expect(() => resolveDefaultAgentId({ agents: { list: [] } })).toThrow("No agents configured");
|
||||
});
|
||||
|
||||
it("preserves legacy first-entry selection while diagnostic lookup stays strict", () => {
|
||||
const missingDefault = { agents: { list: [{ id: "alpha" }, { id: "beta" }] } };
|
||||
expect(resolveDefaultAgentId(missingDefault)).toBe("alpha");
|
||||
expect(tryResolveDefaultAgentId(missingDefault)).toBeUndefined();
|
||||
expect(
|
||||
resolveDefaultAgentId({
|
||||
agents: { list: [{ id: "alpha" }, { id: "beta", default: true }] },
|
||||
}),
|
||||
).toBe("beta");
|
||||
const duplicateDefaults = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", default: true },
|
||||
{ id: "beta", default: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(resolveDefaultAgentId(duplicateDefaults)).toBe("alpha");
|
||||
expect(tryResolveDefaultAgentId(duplicateDefaults)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers a non-throwing diagnostic lookup for malformed rosters", () => {
|
||||
expect(tryResolveDefaultAgentId({ agents: { list: [{ id: "alpha" }] } })).toBeUndefined();
|
||||
for (const marker of ["false", 1]) {
|
||||
expect(
|
||||
tryResolveDefaultAgentId({
|
||||
agents: { entries: { alpha: { default: marker } } },
|
||||
} as unknown as OpenClawConfig),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies own __proto__ fields without changing the listed entry prototype", () => {
|
||||
const entry = JSON.parse('{"__proto__":{"tools":{"allow":["*"]}}}') as Record<string, unknown>;
|
||||
const [listed] = listAgentEntriesWithSource({
|
||||
agents: { entries: { ops: entry } },
|
||||
} as OpenClawConfig);
|
||||
expect(listed).toBeDefined();
|
||||
const listedEntry = listed!.entry;
|
||||
|
||||
expect(Object.getPrototypeOf(listedEntry)).toBe(Object.prototype);
|
||||
expect(Object.hasOwn(listedEntry, "__proto__")).toBe(true);
|
||||
expect(Object.getOwnPropertyDescriptor(listedEntry, "__proto__")?.value).toEqual({
|
||||
tools: { allow: ["*"] },
|
||||
});
|
||||
expect(listedEntry.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentConfig model policy", () => {
|
||||
it("keeps an empty per-agent policy inherited instead of flattening it", () => {
|
||||
|
||||
@@ -8,12 +8,18 @@ import type {
|
||||
AgentDefaultsConfig,
|
||||
} from "../config/types.agent-defaults.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { registerResolvedAgentDir } from "./agent-dir-registry.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "./workspace-default.js";
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
type AgentEntriesConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["entries"]>;
|
||||
type AgentRosterProperty = { kind: "entries" | "list"; value: unknown };
|
||||
export type ListedAgentEntry = {
|
||||
entry: AgentEntry;
|
||||
source: { kind: "entries"; key: string } | { kind: "list"; index: number };
|
||||
};
|
||||
|
||||
/** Per-agent config after applying agent defaults and normalizing scalar fields. */
|
||||
export type ResolvedAgentConfig = {
|
||||
@@ -22,7 +28,10 @@ export type ResolvedAgentConfig = {
|
||||
agentDir?: string;
|
||||
model?: AgentEntry["model"];
|
||||
models?: AgentEntry["models"];
|
||||
params?: AgentEntry["params"];
|
||||
runtime?: AgentEntry["runtime"];
|
||||
modelPolicy?: AgentEntry["modelPolicy"];
|
||||
agentRuntime?: AgentEntry["agentRuntime"];
|
||||
utilityModel?: AgentEntry["utilityModel"];
|
||||
thinkingDefault?: AgentEntry["thinkingDefault"];
|
||||
verboseDefault?: AgentDefaultsConfig["verboseDefault"];
|
||||
@@ -48,41 +57,76 @@ export type ResolvedAgentConfig = {
|
||||
tools?: AgentEntry["tools"];
|
||||
};
|
||||
|
||||
let defaultAgentWarned = false;
|
||||
|
||||
function warnMultipleDefaultAgents(): void {
|
||||
void import("../logging/subsystem.js")
|
||||
.then(({ createSubsystemLogger }) => {
|
||||
createSubsystemLogger("agent-scope").warn(
|
||||
"Multiple agents marked default=true; using the first entry as default.",
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Strip null bytes from paths to prevent ENOTDIR errors. */
|
||||
function stripNullBytes(s: string): string {
|
||||
return s.replaceAll("\0", "");
|
||||
}
|
||||
|
||||
/** Lists valid configured agent entries from config. */
|
||||
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
|
||||
const entries = cfg.agents?.entries;
|
||||
if (entries && typeof entries === "object") {
|
||||
return Object.entries(entries).map(([id, entry]) => Object.assign({ id }, entry));
|
||||
export function listAgentEntriesWithSource(cfg: OpenClawConfig): ListedAgentEntry[] {
|
||||
const roster = readAgentRosterProperty(cfg);
|
||||
if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") {
|
||||
return Object.entries(roster.value).map(([id, entry]) => ({
|
||||
entry: { ...(entry as Omit<AgentEntry, "id">), id },
|
||||
source: { kind: "entries", key: id },
|
||||
}));
|
||||
}
|
||||
const list = cfg.agents?.list;
|
||||
if (!Array.isArray(list)) {
|
||||
if (roster?.kind !== "list" || !Array.isArray(roster.value)) {
|
||||
return [];
|
||||
}
|
||||
return list.filter((entry): entry is AgentEntry => entry !== null && typeof entry === "object");
|
||||
return roster.value.flatMap((entry, index) =>
|
||||
entry !== null && typeof entry === "object"
|
||||
? [{ entry: entry as AgentEntry, source: { kind: "list" as const, index } }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists unique configured agent ids, falling back to the default agent id. */
|
||||
/** Lists valid configured agent entries from either supported representation. */
|
||||
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
|
||||
return listAgentEntriesWithSource(cfg).map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/** Converts either supported roster representation into the canonical keyed shape. */
|
||||
export function toAgentEntriesRecord(entries: readonly AgentEntry[]): AgentEntriesConfig {
|
||||
return Object.fromEntries(
|
||||
entries.map((entry) => {
|
||||
const { id, ...config } = entry;
|
||||
return [id, config];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads the explicitly owned raw roster without normalizing malformed values. */
|
||||
export function readAgentRosterProperty(raw: unknown): AgentRosterProperty | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const agents = (raw as { agents?: unknown }).agents;
|
||||
if (!agents || typeof agents !== "object" || Array.isArray(agents)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = (agents as Record<string, unknown>)["entries"];
|
||||
if (Object.hasOwn(agents, "entries") && entries !== undefined) {
|
||||
return { kind: "entries", value: entries };
|
||||
}
|
||||
const list = (agents as Record<string, unknown>)["list"];
|
||||
if (Object.hasOwn(agents, "list") && list !== undefined) {
|
||||
return { kind: "list", value: list };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when raw config explicitly owns either supported roster representation. */
|
||||
export function hasAgentRosterProperty(raw: unknown): boolean {
|
||||
return readAgentRosterProperty(raw) !== undefined;
|
||||
}
|
||||
|
||||
/** Lists unique configured agent ids. */
|
||||
export function listAgentIds(cfg: OpenClawConfig): string[] {
|
||||
const agents = listAgentEntries(cfg);
|
||||
if (agents.length === 0) {
|
||||
return [DEFAULT_AGENT_ID];
|
||||
if (agents.length === 0 && !hasAgentRosterProperty(cfg)) {
|
||||
// Match resolveDefaultAgentId's Plugin SDK compatibility for raw pre-roster configs.
|
||||
return [LEGACY_IMPLICIT_AGENT_ID];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
@@ -94,29 +138,58 @@ export function listAgentIds(cfg: OpenClawConfig): string[] {
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids.length > 0 ? ids : [DEFAULT_AGENT_ID];
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Resolves the default agent id, warning once when multiple defaults exist. */
|
||||
/** Resolves the configured default while preserving the shipped Plugin SDK legacy shape. */
|
||||
export function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
||||
const agents = listAgentEntries(cfg);
|
||||
if (agents.length === 0) {
|
||||
return DEFAULT_AGENT_ID;
|
||||
// Runtime config loading materializes this entry. Keep the roster-property-absent
|
||||
// case for shipped Plugin SDK callers that still pass a pre-roster config object.
|
||||
if (!hasAgentRosterProperty(cfg)) {
|
||||
return LEGACY_IMPLICIT_AGENT_ID;
|
||||
}
|
||||
throw new Error("No agents configured. Run `openclaw onboard` or `openclaw agents add` first.");
|
||||
}
|
||||
const defaults = agents.filter((agent) => agent?.default);
|
||||
if (defaults.length > 1 && !defaultAgentWarned) {
|
||||
defaultAgentWarned = true;
|
||||
warnMultipleDefaultAgents();
|
||||
}
|
||||
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
|
||||
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
|
||||
// Runtime config loading canonicalizes zero/multiple markers before this helper is called.
|
||||
// External SDK callers may still pass the shipped list shape, which chose the first candidate.
|
||||
return normalizeAgentId((agents.find((agent) => agent?.default === true) ?? agents[0])!.id);
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
/** Returns the configured default when diagnostics must tolerate an invalid raw roster. */
|
||||
export function tryResolveDefaultAgentId(cfg: OpenClawConfig): string | undefined {
|
||||
const agents = listAgentEntries(cfg);
|
||||
const defaults = agents.filter((agent) => agent?.default === true);
|
||||
if (defaults.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAgentId(defaults[0]!.id);
|
||||
}
|
||||
|
||||
export function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
/** Resolves the authored entry object for in-place canonical config mutations. */
|
||||
export function resolveMutableAgentEntry(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Pick<AgentEntry, "model"> | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const roster = readAgentRosterProperty(cfg);
|
||||
if (roster?.kind === "entries" && roster.value && typeof roster.value === "object") {
|
||||
const entries = roster.value as AgentEntriesConfig;
|
||||
const key = Object.keys(entries).find((candidate) => normalizeAgentId(candidate) === id);
|
||||
return key ? entries[key] : undefined;
|
||||
}
|
||||
if (roster?.kind === "list" && Array.isArray(roster.value)) {
|
||||
return (roster.value as AgentEntry[]).find((entry) => normalizeAgentId(entry?.id) === id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Resolves merged config for one agent id. */
|
||||
export function resolveAgentConfig(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -137,7 +210,10 @@ export function resolveAgentConfig(
|
||||
? entry.model
|
||||
: undefined,
|
||||
...(entry.models ? { models: entry.models } : {}),
|
||||
...(entry.params ? { params: entry.params } : {}),
|
||||
...(entry.runtime ? { runtime: entry.runtime } : {}),
|
||||
...(hasExplicitModelPolicyAllow(entry.modelPolicy) ? { modelPolicy: entry.modelPolicy } : {}),
|
||||
...(entry.agentRuntime ? { agentRuntime: entry.agentRuntime } : {}),
|
||||
utilityModel: readStringValue(entry.utilityModel),
|
||||
thinkingDefault: entry.thinkingDefault,
|
||||
verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault,
|
||||
|
||||
@@ -1118,7 +1118,10 @@ describe("resolveAgentConfig", () => {
|
||||
it("uses OPENCLAW_HOME for default agent workspace", () => {
|
||||
const home = path.join(path.sep, "srv", "openclaw-home");
|
||||
withEnv({ OPENCLAW_HOME: home }, () => {
|
||||
const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main");
|
||||
const workspace = resolveAgentWorkspaceDir(
|
||||
{ agents: { entries: { main: { default: true } } } },
|
||||
"main",
|
||||
);
|
||||
expect(workspace).toBe(path.join(path.resolve(home), ".openclaw", "workspace"));
|
||||
});
|
||||
});
|
||||
@@ -1131,7 +1134,10 @@ describe("resolveAgentConfig", () => {
|
||||
OPENCLAW_HOME: path.join(path.sep, "srv", "openclaw-home"),
|
||||
},
|
||||
() => {
|
||||
const workspace = resolveAgentWorkspaceDir({} as OpenClawConfig, "main");
|
||||
const workspace = resolveAgentWorkspaceDir(
|
||||
{ agents: { entries: { main: { default: true } } } },
|
||||
"main",
|
||||
);
|
||||
expect(workspace).toBe(path.resolve(workspaceDir));
|
||||
},
|
||||
);
|
||||
|
||||
+12
-19
@@ -26,19 +26,24 @@ import { resolveEffectiveAgentSkillFilter } from "../skills/discovery/agent-filt
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveMutableAgentEntry,
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "./agent-scope-config.js";
|
||||
export {
|
||||
listAgentEntries,
|
||||
listAgentEntriesWithSource,
|
||||
listAgentIds,
|
||||
resolveMutableAgentEntry,
|
||||
toAgentEntriesRecord,
|
||||
resolveAgentConfig,
|
||||
resolveAgentContextLimits,
|
||||
resolveAgentDir,
|
||||
resolveDefaultAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
tryResolveDefaultAgentId,
|
||||
type ResolvedAgentConfig,
|
||||
} from "./agent-scope-config.js";
|
||||
|
||||
@@ -367,20 +372,6 @@ export function resolveAgentEffectiveModelPrimary(
|
||||
);
|
||||
}
|
||||
|
||||
function findMutableAgentEntry(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
): Pick<AgentConfig, "model"> | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const entryKey = Object.keys(cfg.agents?.entries ?? {}).find(
|
||||
(key) => normalizeAgentId(key) === id,
|
||||
);
|
||||
if (entryKey) {
|
||||
return cfg.agents?.entries?.[entryKey];
|
||||
}
|
||||
return cfg.agents?.list?.find((entry) => normalizeAgentId(entry?.id) === id);
|
||||
}
|
||||
|
||||
function updateAgentModelPrimary(
|
||||
existing: AgentModelConfig | undefined,
|
||||
primary: string,
|
||||
@@ -403,7 +394,7 @@ export function setAgentEffectiveModelPrimary(
|
||||
// forceAgent pins the write to the agent entry even without an explicit
|
||||
// model, so a per-agent override never rewrites the shared default route.
|
||||
if (options.forceAgent || resolveAgentExplicitModelPrimary(cfg, id)) {
|
||||
const entry = findMutableAgentEntry(cfg, id);
|
||||
const entry = resolveMutableAgentEntry(cfg, id);
|
||||
if (entry) {
|
||||
entry.model = updateAgentModelPrimary(entry.model, primary);
|
||||
return "agent";
|
||||
@@ -543,10 +534,12 @@ export function resolveRunModelFallbacksOverride(params: {
|
||||
if (!params.cfg) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveAgentModelFallbacksOverride(
|
||||
params.cfg,
|
||||
resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }),
|
||||
);
|
||||
const explicitAgentId = normalizeOptionalString(params.agentId);
|
||||
const agentId = explicitAgentId
|
||||
? normalizeAgentId(explicitAgentId)
|
||||
: (parseAgentSessionKey(params.sessionKey)?.agentId ??
|
||||
(listAgentIds(params.cfg).length > 0 ? resolveDefaultAgentId(params.cfg) : undefined));
|
||||
return agentId ? resolveAgentModelFallbacksOverride(params.cfg, agentId) : undefined;
|
||||
}
|
||||
|
||||
export function hasConfiguredModelFallbacks(params: {
|
||||
|
||||
@@ -395,6 +395,32 @@ describe("resolveSubagentToolPolicyForSession", () => {
|
||||
});
|
||||
|
||||
describe("resolveEffectiveToolPolicy", () => {
|
||||
it("applies implicit-main defaults tool restrictions to a pre-roster config", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { tools: { deny: ["exec"] } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveEffectiveToolPolicy({ config: cfg });
|
||||
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.agentPolicy).toEqual({ deny: ["exec"] });
|
||||
});
|
||||
|
||||
it("uses the configured default agent policy for an unscoped session alias", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
entries: {
|
||||
ops: { default: true, tools: { deny: ["exec"] } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = resolveEffectiveToolPolicy({ config: cfg, sessionKey: "main" });
|
||||
|
||||
expect(result.agentId).toBe("ops");
|
||||
expect(result.agentPolicy).toEqual({ deny: ["exec"] });
|
||||
});
|
||||
|
||||
it("keeps slash-containing modelId scoped to the selected provider", () => {
|
||||
const cfg = {
|
||||
tools: {
|
||||
|
||||
@@ -15,13 +15,14 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
parseRawSessionConversationRef,
|
||||
parseThreadSessionSuffix,
|
||||
} from "../sessions/session-key-utils.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
|
||||
import { hasAgentRosterProperty } from "./agent-scope-config.js";
|
||||
import { listAgentEntries, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { resolveProviderToolPolicy } from "./provider-tool-policy.js";
|
||||
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
|
||||
import type { SandboxToolPolicy } from "./sandbox.js";
|
||||
@@ -382,10 +383,21 @@ export function resolveEffectiveToolPolicy(params: {
|
||||
: undefined;
|
||||
const agentId =
|
||||
explicitAgentId ??
|
||||
(params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined);
|
||||
(params.sessionKey ? parseAgentSessionKey(params.sessionKey)?.agentId : undefined) ??
|
||||
(params.config &&
|
||||
(!hasAgentRosterProperty(params.config) || listAgentEntries(params.config).length > 0)
|
||||
? resolveDefaultAgentId(params.config)
|
||||
: undefined);
|
||||
const agentConfig =
|
||||
params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined;
|
||||
const agentTools = agentConfig?.tools;
|
||||
// Shipped pre-roster SDK inputs allowed this raw defaults shape. Runtime-loaded
|
||||
// configs materialize main, but direct SDK callers still need its deny policy.
|
||||
const implicitDefaultTools = params.config
|
||||
? (params.config.agents?.defaults as { tools?: AgentToolsConfig } | undefined)?.tools
|
||||
: undefined;
|
||||
const agentTools =
|
||||
agentConfig?.tools ??
|
||||
(params.config && !hasAgentRosterProperty(params.config) ? implicitDefaultTools : undefined);
|
||||
const globalTools = params.config?.tools;
|
||||
|
||||
const profile = agentTools?.profile ?? globalTools?.profile;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../../config/model-input.js";
|
||||
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { listAgentEntries } from "../agent-scope-config.js";
|
||||
|
||||
/** Provider/profile ids that may need external CLI auth discovery. */
|
||||
export type ExternalCliAuthScope = {
|
||||
@@ -123,7 +124,7 @@ export function resolveExternalCliAuthScopeFromConfig(
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of Object.values(cfg.agents?.entries ?? {})) {
|
||||
for (const agent of listAgentEntries(cfg)) {
|
||||
addProviderScopeFromModelConfig(providerIds, agent.model);
|
||||
addProviderScopeFromModelConfig(providerIds, agent.subagents?.model);
|
||||
addExternalCliRuntimeScopeFromModelMap(providerIds, agent.models);
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
|
||||
import {
|
||||
AUTH_PROFILE_FILENAME,
|
||||
AUTH_STATE_FILENAME,
|
||||
@@ -14,22 +13,32 @@ import {
|
||||
} from "./path-constants.js";
|
||||
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
|
||||
|
||||
function resolveAuthAgentDir(agentDir?: string): string {
|
||||
if (agentDir) {
|
||||
return resolveUserPath(agentDir);
|
||||
}
|
||||
const configuredMainAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim();
|
||||
if (configuredMainAgentDir) {
|
||||
return resolveUserPath(configuredMainAgentDir);
|
||||
}
|
||||
// The no-argument auth-store API names the shipped shared main store, not a
|
||||
// configured-agent fallback. Roster-aware callers pass their resolved dir.
|
||||
return path.join(resolveStateDir(), "agents", "main", "agent");
|
||||
}
|
||||
|
||||
/** Resolve the persisted auth profile store path for an agent dir. */
|
||||
export function resolveAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, AUTH_PROFILE_FILENAME);
|
||||
return path.join(resolveAuthAgentDir(agentDir), AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
/** Resolve the legacy auth store path used by migration code. */
|
||||
export function resolveLegacyAuthStorePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, LEGACY_AUTH_FILENAME);
|
||||
return path.join(resolveAuthAgentDir(agentDir), LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
/** Resolve the auth-state sidecar path for usage/cooldown metadata. */
|
||||
export function resolveAuthStatePath(agentDir?: string): string {
|
||||
const resolved = resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
return path.join(resolved, AUTH_STATE_FILENAME);
|
||||
return path.join(resolveAuthAgentDir(agentDir), AUTH_STATE_FILENAME);
|
||||
}
|
||||
|
||||
/** Resolve the user-facing auth profile database path. */
|
||||
|
||||
@@ -44,6 +44,16 @@ describe("path-resolve helpers (direct-import coverage attribution)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("honors OPENCLAW_AGENT_DIR in both no-argument auth path implementations", () => {
|
||||
const relocatedAgentDir = path.join(stateDir, "relocated-main-agent");
|
||||
withEnv({ OPENCLAW_STATE_DIR: stateDir, OPENCLAW_AGENT_DIR: relocatedAgentDir }, () => {
|
||||
expect(path.dirname(resolveAuthStorePath())).toBe(relocatedAgentDir);
|
||||
expect(resolveAuthStorePathForDisplay()).toBe(
|
||||
path.join(relocatedAgentDir, "openclaw-agent.sqlite"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveLegacyAuthStorePath joins agentDir with the legacy auth filename", () => {
|
||||
const agentDir = path.join(stateDir, "agents", "main", "agent");
|
||||
const resolved = resolveLegacyAuthStorePath(agentDir);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { sha256HexPrefix } from "../../infra/crypto-digest.js";
|
||||
import {
|
||||
clearNodeSqliteKyselyCacheForDatabase,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "../../state/openclaw-state-db.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { resolveRegisteredAgentIdForDir } from "../agent-dir-registry.js";
|
||||
import { resolveDefaultAgentDir } from "../agent-scope-config.js";
|
||||
|
||||
type AuthProfileDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
@@ -37,7 +37,13 @@ type AuthProfileDatabase = Pick<
|
||||
const PRIMARY_ROW_KEY = "primary";
|
||||
|
||||
function resolveAgentDir(agentDir?: string): string {
|
||||
return resolveUserPath(agentDir ?? resolveDefaultAgentDir({}));
|
||||
if (agentDir) {
|
||||
return resolveUserPath(agentDir);
|
||||
}
|
||||
const configuredMainAgentDir = process.env.OPENCLAW_AGENT_DIR?.trim();
|
||||
return configuredMainAgentDir
|
||||
? resolveUserPath(configuredMainAgentDir)
|
||||
: path.join(resolveStateDir(), "agents", "main", "agent");
|
||||
}
|
||||
|
||||
function inferAgentIdFromDir(agentDir: string): string {
|
||||
|
||||
@@ -12,9 +12,13 @@ import { loadExecApprovals } from "../infra/exec-approvals.js";
|
||||
import { sendMessage } from "../infra/outbound/message.js";
|
||||
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { buildSystemRunPreparePayload } from "../test-utils/system-run-prepare-payload.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
const createExecTool = (
|
||||
defaults?: Parameters<typeof createExecToolImpl>[0],
|
||||
): ReturnType<typeof createExecToolImpl> => createExecToolImpl({ agentId: "main", ...defaults });
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
|
||||
@@ -10,9 +10,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
|
||||
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { resetProcessRegistryForTests } from "./bash-process-registry.test-support.js";
|
||||
import { createExecTool } from "./bash-tools.exec.js";
|
||||
import { createExecTool as createExecToolImpl } from "./bash-tools.exec.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
|
||||
const createExecTool = (
|
||||
defaults?: Parameters<typeof createExecToolImpl>[0],
|
||||
): ReturnType<typeof createExecToolImpl> => createExecToolImpl({ agentId: "main", ...defaults });
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
readGatewayCallOptions: vi.fn(() => ({})),
|
||||
|
||||
@@ -49,6 +49,7 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { safeJsonStringify } from "../utils/safe-json.js";
|
||||
import { splitShellArgs } from "../utils/shell-argv.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
import type { HookContext } from "./agent-tools.before-tool-call.js";
|
||||
import { stripMalformedXmlArgValueSuffixFromKeys } from "./agent-tools.params.js";
|
||||
import { markBackgrounded } from "./bash-process-registry.js";
|
||||
@@ -1305,9 +1306,7 @@ function resolveExecReviewerDefaults(params: { defaults?: ExecToolDefaults; agen
|
||||
}
|
||||
const cfg = params.defaults?.config;
|
||||
const agentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const agentExec = agentId
|
||||
? cfg?.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId)?.tools?.exec
|
||||
: undefined;
|
||||
const agentExec = agentId && cfg ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
||||
return agentExec?.reviewer ?? cfg?.tools?.exec?.reviewer;
|
||||
}
|
||||
|
||||
|
||||
+11
-10
@@ -366,12 +366,14 @@ function supportsPreparedOpenAIAuth(ctx: Parameters<AgentHarness["supports"]>[0]
|
||||
|
||||
function runSideQuestion(overrides: Partial<RunBtwSideQuestionParams> = {}) {
|
||||
return runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
cfg: { agents: { entries: { main: { default: true } } } } as never,
|
||||
agentDir: DEFAULT_AGENT_DIR,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
model: DEFAULT_MODEL,
|
||||
question: DEFAULT_QUESTION,
|
||||
sessionEntry: createSessionEntry(),
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
storePath: DEFAULT_STORE_PATH,
|
||||
resolvedReasoningLevel: DEFAULT_REASONING_LEVEL,
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
@@ -704,7 +706,7 @@ describe("runBtwSideQuestion", () => {
|
||||
);
|
||||
|
||||
const result = await runBtwSideQuestion({
|
||||
cfg: {} as never,
|
||||
cfg: { agents: { entries: { main: { default: true } } } } as never,
|
||||
agentDir: DEFAULT_AGENT_DIR,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
model: DEFAULT_MODEL,
|
||||
@@ -979,12 +981,16 @@ describe("runBtwSideQuestion", () => {
|
||||
resolveModelWithRegistryMock.mockReturnValue(platformModel);
|
||||
resolveSessionAuthProfileOverrideMock.mockResolvedValue(undefined);
|
||||
ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} });
|
||||
getApiKeyForModelMock.mockResolvedValue({
|
||||
apiKey: undefined,
|
||||
mode: "api-key",
|
||||
source: "none",
|
||||
});
|
||||
|
||||
await expect(runSideQuestion({ provider: "openai", model: "gpt-5.5" })).resolves.toEqual({
|
||||
text: "Codex side answer.",
|
||||
});
|
||||
|
||||
expect(getApiKeyForModelMock).not.toHaveBeenCalled();
|
||||
expect(codexSideQuestionMock).toHaveBeenCalledOnce();
|
||||
const preparedRuntimeAuth = (
|
||||
mockArg(codexSideQuestionMock, 0, 0) as {
|
||||
@@ -997,20 +1003,13 @@ describe("runBtwSideQuestion", () => {
|
||||
).preparedRuntimeAuth;
|
||||
expect(preparedRuntimeAuth?.plan).toMatchObject({
|
||||
harnessAuthProvider: "openai",
|
||||
deferredRouteSupport: {
|
||||
requestTransportOverrides: "none",
|
||||
runtimePolicy: { compatibleIds: ["openclaw", "codex"] },
|
||||
},
|
||||
});
|
||||
expect(preparedRuntimeAuth?.plan?.modelRoute).toBeUndefined();
|
||||
expect(preparedRuntimeAuth?.plan?.forwardedAuthProfileId).toBeUndefined();
|
||||
expect(preparedRuntimeAuth?.resolvedApiKey).toBeUndefined();
|
||||
expect(Object.keys(preparedRuntimeAuth?.authProfileStore?.profiles ?? {})).toEqual([]);
|
||||
expect(supports).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modelProvider: expect.objectContaining({
|
||||
requestTransportOverrides: "none",
|
||||
runtimePolicy: { compatibleIds: ["openclaw", "codex"] },
|
||||
preparedAuth: { source: "harness" },
|
||||
}),
|
||||
}),
|
||||
@@ -2209,6 +2208,8 @@ describe("runBtwSideQuestion", () => {
|
||||
model: "us.anthropic.claude-sonnet-4-5-v1:0",
|
||||
question: DEFAULT_QUESTION,
|
||||
sessionEntry: createSessionEntry(),
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
storePath: DEFAULT_STORE_PATH,
|
||||
resolvedReasoningLevel: DEFAULT_REASONING_LEVEL,
|
||||
opts: {},
|
||||
isNewSession: false,
|
||||
|
||||
@@ -2227,7 +2227,10 @@ describe("runCliAgent spawn path", () => {
|
||||
timeoutMs: 3_600_000,
|
||||
}),
|
||||
);
|
||||
const rejection = expect(run).rejects.toThrow(/produced no output for 900s/);
|
||||
const rejection = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(stdin.write).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -2243,14 +2246,16 @@ describe("runCliAgent spawn path", () => {
|
||||
);
|
||||
|
||||
// Base watchdog (600s cap for a 1h budget) must not kill the quiet tool.
|
||||
await vi.advanceTimersByTimeAsync(650_000);
|
||||
vi.advanceTimersByTime(650_000);
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
|
||||
// The blocked-tool floor (15min of quiet) still terminates a wedged tool.
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
vi.advanceTimersByTime(300_000);
|
||||
expect(cancel).toHaveBeenCalledWith("manual-cancel");
|
||||
await rejection;
|
||||
const error = await rejection;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toMatch(/produced no output for 900s/);
|
||||
// Watchdog-killed turns must keep timeout provenance for active tools.
|
||||
expect(toolErrorEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveClaudeLiveMode } from "./claude-live-session-policy.js";
|
||||
import { readConfiguredExecPolicy } from "./claude-live-session.test-support.js";
|
||||
import type { PreparedCliRunContext } from "./types.js";
|
||||
|
||||
describe("resolveClaudeLiveMode", () => {
|
||||
it("keeps root on Claude default permissions while preserving YOLO elsewhere", () => {
|
||||
@@ -11,3 +13,28 @@ describe("resolveClaudeLiveMode", () => {
|
||||
expect(resolveClaudeLiveMode("allowlist", "on-miss", 1000)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude live configured exec policy", () => {
|
||||
it("uses the configured default agent for an unscoped legacy session key", () => {
|
||||
const context = {
|
||||
params: {
|
||||
sessionKey: "main",
|
||||
config: {
|
||||
tools: { exec: { security: "full", ask: "off" } },
|
||||
agents: {
|
||||
entries: {
|
||||
main: {},
|
||||
ops: { default: true, tools: { exec: { security: "deny", ask: "always" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PreparedCliRunContext;
|
||||
|
||||
expect(readConfiguredExecPolicy(context)).toEqual({
|
||||
agentId: "ops",
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
|
||||
import "./claude-live-session.js";
|
||||
import type { PreparedCliRunContext } from "./types.js";
|
||||
|
||||
type BuildClaudeLiveArgsParams = {
|
||||
args: string[];
|
||||
@@ -11,6 +12,11 @@ type BuildClaudeLiveArgsParams = {
|
||||
|
||||
type ClaudeLiveSessionTestApi = {
|
||||
buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[];
|
||||
readConfiguredExecPolicy(context: PreparedCliRunContext): {
|
||||
security: string;
|
||||
ask: string;
|
||||
agentId: string;
|
||||
};
|
||||
resetClaudeLiveSessionsForTest(): void;
|
||||
};
|
||||
|
||||
@@ -24,6 +30,10 @@ export function buildClaudeLiveArgs(params: BuildClaudeLiveArgsParams): string[]
|
||||
return getTestApi().buildClaudeLiveArgs(params);
|
||||
}
|
||||
|
||||
export function readConfiguredExecPolicy(context: PreparedCliRunContext) {
|
||||
return getTestApi().readConfiguredExecPolicy(context);
|
||||
}
|
||||
|
||||
export function resetClaudeLiveSessionsForTest(): void {
|
||||
getTestApi().resetClaudeLiveSessionsForTest();
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
} from "../../infra/exec-approvals.js";
|
||||
import { BLOCKED_TOOL_CALL_ABORT_FLOOR_MS } from "../../logging/diagnostic-run-activity.js";
|
||||
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
LEGACY_IMPLICIT_AGENT_ID,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import {
|
||||
CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
|
||||
createCliJsonlStreamingParser,
|
||||
@@ -333,6 +337,7 @@ function buildClaudeLiveArgs(params: {
|
||||
if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.claudeLiveSessionTestApi")] = {
|
||||
buildClaudeLiveArgs,
|
||||
readConfiguredExecPolicy,
|
||||
resetClaudeLiveSessionsForTest,
|
||||
};
|
||||
}
|
||||
@@ -974,9 +979,17 @@ function readConfiguredExecPolicy(context: PreparedCliRunContext): {
|
||||
ask: ExecAsk;
|
||||
agentId: string;
|
||||
} {
|
||||
const agentId = context.params.agentId ?? resolveAgentIdFromSessionKey(context.params.sessionKey);
|
||||
const agentExec = context.params.config?.agents?.list?.find((agent) => agent.id === agentId)
|
||||
?.tools?.exec;
|
||||
const agentId =
|
||||
context.params.agentId ??
|
||||
resolveAgentIdFromSessionKey(
|
||||
context.params.sessionKey,
|
||||
context.params.config
|
||||
? resolveDefaultAgentId(context.params.config)
|
||||
: LEGACY_IMPLICIT_AGENT_ID,
|
||||
);
|
||||
const agentExec = context.params.config
|
||||
? resolveAgentConfig(context.params.config, agentId)?.tools?.exec
|
||||
: undefined;
|
||||
const exec = agentExec ?? context.params.config?.tools?.exec;
|
||||
const configured = resolveExecModePolicy({
|
||||
mode: exec?.mode,
|
||||
|
||||
@@ -104,7 +104,9 @@ function buildPreparedCliRunContext(params: {
|
||||
|
||||
return {
|
||||
params: {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
|
||||
*/
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
assertContextEngineHostSupport,
|
||||
buildGenericCliContextEngineHostSupport,
|
||||
@@ -34,13 +35,18 @@ import type {
|
||||
} from "../../plugins/cli-backend.types.js";
|
||||
import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
LEGACY_IMPLICIT_AGENT_ID,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js";
|
||||
import { resolveSkillsPromptForRun } from "../../skills/loading/workspace.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveAgentWorkspaceDir } from "../agent-scope-config.js";
|
||||
import { hasAgentRosterProperty, resolveAgentWorkspaceDir } from "../agent-scope-config.js";
|
||||
import { resolveAgentConfig, resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js";
|
||||
import { resolveApiKeyForProfile } from "../auth-profiles/oauth.js";
|
||||
@@ -325,7 +331,25 @@ function shouldRefreshAuthProfileForExecution(params: {
|
||||
export async function prepareCliRunContext(
|
||||
inputParams: RunCliAgentParams,
|
||||
): Promise<PreparedCliRunContext> {
|
||||
let params = inputParams;
|
||||
let params = inputParams.config ? inputParams : { ...inputParams, config: getRuntimeConfig() };
|
||||
const runConfig = params.config!;
|
||||
const selectedOwner = normalizeAgentId(
|
||||
params.agentId?.trim() ||
|
||||
parseAgentSessionKey(params.sessionKey)?.agentId ||
|
||||
LEGACY_IMPLICIT_AGENT_ID,
|
||||
);
|
||||
// Direct CLI-runner callers predate roster-aware ownership. Adapt that SDK
|
||||
// input only for strict workspace admission; keep the original config object
|
||||
// for backend hooks, sandboxing, and context-engine identity contracts.
|
||||
const workspaceConfig = hasAgentRosterProperty(runConfig)
|
||||
? runConfig
|
||||
: ({
|
||||
...runConfig,
|
||||
agents: {
|
||||
...runConfig.agents,
|
||||
entries: { [selectedOwner]: { default: true } },
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
const started = Date.now();
|
||||
const executionMode = params.executionMode ?? "agent";
|
||||
const isSideQuestion = executionMode === "side-question";
|
||||
@@ -333,7 +357,7 @@ export async function prepareCliRunContext(
|
||||
workspaceDir: params.workspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
config: workspaceConfig,
|
||||
});
|
||||
const resolvedWorkspace = workspaceResolution.workspaceDir;
|
||||
const redactedSessionId = redactRunIdentifier(params.sessionId);
|
||||
|
||||
@@ -91,12 +91,14 @@ describe("attempt execution prompt materialization", () => {
|
||||
});
|
||||
|
||||
describe("persistSessionEntry", () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
|
||||
it("clears stale local entries when guarded persistence sees no persisted entry", async () => {
|
||||
const dir = tempDirs.make("openclaw-session-store-");
|
||||
try {
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
const sessionStore = {
|
||||
main: {
|
||||
[sessionKey]: {
|
||||
sessionId: "stale",
|
||||
updatedAt: 1,
|
||||
},
|
||||
@@ -106,9 +108,9 @@ describe("persistSessionEntry", () => {
|
||||
// memory must be cleared too so later turns do not reuse stale entries.
|
||||
const persisted = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: sessionStore.main,
|
||||
initialEntry: sessionStore[sessionKey],
|
||||
entry: {
|
||||
sessionId: "stale",
|
||||
updatedAt: 2,
|
||||
@@ -117,7 +119,7 @@ describe("persistSessionEntry", () => {
|
||||
});
|
||||
|
||||
expect(persisted).toBeUndefined();
|
||||
expect(sessionStore.main).toBeUndefined();
|
||||
expect(sessionStore[sessionKey]).toBeUndefined();
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
}
|
||||
@@ -155,12 +157,12 @@ describe("persistSessionEntry", () => {
|
||||
if (current.pinnedAt === undefined) {
|
||||
delete currentEntry.pinnedAt;
|
||||
}
|
||||
await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry);
|
||||
const sessionStore = { main: staleEntry };
|
||||
await replaceSessionEntry({ sessionKey, storePath }, currentEntry);
|
||||
const sessionStore = { [sessionKey]: staleEntry };
|
||||
|
||||
const persisted = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: staleEntry,
|
||||
entry: {
|
||||
@@ -174,10 +176,10 @@ describe("persistSessionEntry", () => {
|
||||
expect(persisted?.label).toBe(expected.label);
|
||||
expect(persisted?.pinnedAt).toBe(expected.pinnedAt);
|
||||
expect(persisted?.updatedAt).toBeGreaterThanOrEqual(currentEntry.updatedAt);
|
||||
expect(sessionStore.main).toEqual(persisted);
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
|
||||
).toEqual(persisted);
|
||||
expect(sessionStore[sessionKey]).toEqual(persisted);
|
||||
expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual(
|
||||
persisted,
|
||||
);
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
}
|
||||
@@ -201,12 +203,12 @@ describe("persistSessionEntry", () => {
|
||||
model: "gpt-5.4",
|
||||
sendPolicy: "deny",
|
||||
};
|
||||
await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry);
|
||||
const sessionStore = { main: initialEntry };
|
||||
await replaceSessionEntry({ sessionKey, storePath }, currentEntry);
|
||||
const sessionStore = { [sessionKey]: initialEntry };
|
||||
|
||||
const persisted = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry,
|
||||
entry: {
|
||||
@@ -224,9 +226,9 @@ describe("persistSessionEntry", () => {
|
||||
});
|
||||
expect(persisted?.elevatedLevel).toBeUndefined();
|
||||
expect(persisted?.inheritedToolAllow).toBeUndefined();
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
|
||||
).toEqual(persisted);
|
||||
expect(loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" })).toEqual(
|
||||
persisted,
|
||||
);
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
}
|
||||
@@ -240,11 +242,11 @@ describe("persistSessionEntry", () => {
|
||||
sessionId: "deleted-session",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const sessionStore = { main: staleEntry };
|
||||
const sessionStore = { [sessionKey]: staleEntry };
|
||||
|
||||
const persisted = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: staleEntry,
|
||||
entry: {
|
||||
@@ -254,9 +256,9 @@ describe("persistSessionEntry", () => {
|
||||
});
|
||||
|
||||
expect(persisted).toBeUndefined();
|
||||
expect(sessionStore.main).toBeUndefined();
|
||||
expect(sessionStore[sessionKey]).toBeUndefined();
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
|
||||
loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" }),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
@@ -271,20 +273,18 @@ describe("persistSessionEntry", () => {
|
||||
sessionId: "deleted-session",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const sessionStore = {
|
||||
main: staleEntry,
|
||||
};
|
||||
const sessionStore = { [sessionKey]: staleEntry };
|
||||
|
||||
const first = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: staleEntry,
|
||||
entry: staleEntry,
|
||||
});
|
||||
const second = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: staleEntry,
|
||||
entry: {
|
||||
@@ -295,9 +295,9 @@ describe("persistSessionEntry", () => {
|
||||
|
||||
expect(first).toBeUndefined();
|
||||
expect(second).toBeUndefined();
|
||||
expect(sessionStore.main).toBeUndefined();
|
||||
expect(sessionStore[sessionKey]).toBeUndefined();
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey: "main", storePath, readConsistency: "latest" }),
|
||||
loadSessionEntry({ sessionKey, storePath, readConsistency: "latest" }),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
@@ -316,7 +316,7 @@ describe("persistSessionEntry", () => {
|
||||
|
||||
const persisted = await persistSessionEntry({
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
initialEntry: entry,
|
||||
entry,
|
||||
@@ -324,7 +324,7 @@ describe("persistSessionEntry", () => {
|
||||
});
|
||||
|
||||
expect(persisted?.sessionId).toBe("created-session");
|
||||
expect(sessionStore.main?.sessionId).toBe("created-session");
|
||||
expect(sessionStore[sessionKey]?.sessionId).toBe("created-session");
|
||||
} finally {
|
||||
clearSessionStoreCacheForTest();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-m
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import { resolveNonNegativeNumber } from "../../shared/number-coercion.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope.js";
|
||||
import { clearCliSession, setCliSessionBinding, setCliSessionId } from "../cli-session.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { clearMainSessionRecoveryAfterAgentRun } from "../main-session-recovery-clear.js";
|
||||
@@ -141,6 +142,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
|
||||
entry,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
newSessionId: sessionId,
|
||||
});
|
||||
next.usageFamilyKey = entry.usageFamilyKey ?? sessionKey;
|
||||
|
||||
@@ -256,13 +256,15 @@ export function resolveStoredSessionKeyForSessionId(opts: {
|
||||
agentId?: string;
|
||||
}): SessionKeyResolution {
|
||||
const sessionId = opts.sessionId.trim();
|
||||
const storeAgentId = opts.agentId?.trim() ? normalizeAgentId(opts.agentId) : undefined;
|
||||
const storeAgentId = opts.agentId?.trim()
|
||||
? normalizeAgentId(opts.agentId)
|
||||
: resolveDefaultAgentId(opts.cfg);
|
||||
const storePath = resolveStorePath(opts.cfg.session?.store, {
|
||||
agentId: storeAgentId,
|
||||
});
|
||||
const sessionStore = loadCommandSessionStore({
|
||||
storePath,
|
||||
...(storeAgentId ? { agentId: storeAgentId } : {}),
|
||||
agentId: storeAgentId,
|
||||
});
|
||||
if (!sessionId) {
|
||||
return { sessionKey: undefined, sessionStore, storePath };
|
||||
@@ -311,7 +313,7 @@ export function resolveSessionKeyForRequest(opts: {
|
||||
const storeAgentId = explicitSessionKey
|
||||
? isUnscopedSessionKeySentinel(explicitSessionKey)
|
||||
? (requestedAgentId ?? defaultAgentId)
|
||||
: resolveAgentIdFromSessionKey(explicitSessionKey)
|
||||
: resolveAgentIdFromSessionKey(explicitSessionKey, defaultAgentId)
|
||||
: (requestedAgentId ?? defaultAgentId);
|
||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
||||
agentId: storeAgentId,
|
||||
@@ -407,7 +409,7 @@ export function resolveSession(opts: {
|
||||
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
|
||||
const sessionAgentId = opts.agentId?.trim()
|
||||
? normalizeAgentId(opts.agentId)
|
||||
: resolveAgentIdFromSessionKey(sessionKey);
|
||||
: resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(opts.cfg));
|
||||
|
||||
const resetType = resolveSessionResetType({ sessionKey });
|
||||
const channelReset = resolveChannelResetConfig({
|
||||
|
||||
@@ -3,20 +3,27 @@ import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { modelSelectionShouldEnsureCopilotRuntimePlugin } from "./copilot-routing.js";
|
||||
|
||||
const emptyCfg = {} as OpenClawConfig;
|
||||
function withDefaultRoster(config: OpenClawConfig = {}): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
agents: { entries: { main: { default: true } }, ...config.agents },
|
||||
};
|
||||
}
|
||||
|
||||
const emptyCfg = withDefaultRoster();
|
||||
|
||||
function cfgWithProviderRuntime(id: string): OpenClawConfig {
|
||||
return {
|
||||
return withDefaultRoster({
|
||||
models: {
|
||||
providers: {
|
||||
"github-copilot": { agentRuntime: { id } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig);
|
||||
}
|
||||
|
||||
function cfgWithModelRuntime(modelId: string, id: string): OpenClawConfig {
|
||||
return {
|
||||
return withDefaultRoster({
|
||||
models: {
|
||||
providers: {
|
||||
"github-copilot": {
|
||||
@@ -24,7 +31,7 @@ function cfgWithModelRuntime(modelId: string, id: string): OpenClawConfig {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig);
|
||||
}
|
||||
|
||||
describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
|
||||
@@ -84,7 +91,7 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
|
||||
it("model-scope override takes precedence over provider scope", () => {
|
||||
// A model override can intentionally opt out even when the provider default
|
||||
// opts into the Copilot runtime plugin.
|
||||
const cfg = {
|
||||
const cfg = withDefaultRoster({
|
||||
models: {
|
||||
providers: {
|
||||
"github-copilot": {
|
||||
@@ -93,7 +100,7 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig);
|
||||
expect(
|
||||
modelSelectionShouldEnsureCopilotRuntimePlugin({
|
||||
model: "github-copilot/gpt-4o",
|
||||
@@ -110,13 +117,13 @@ describe("modelSelectionShouldEnsureCopilotRuntimePlugin", () => {
|
||||
});
|
||||
|
||||
it("returns false for other providers regardless of agentRuntime config", () => {
|
||||
const cfg = {
|
||||
const cfg = withDefaultRoster({
|
||||
models: {
|
||||
providers: {
|
||||
openai: { agentRuntime: { id: "copilot" } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig);
|
||||
expect(
|
||||
modelSelectionShouldEnsureCopilotRuntimePlugin({ model: "openai/gpt-4o", config: cfg }),
|
||||
).toBe(false);
|
||||
|
||||
@@ -304,7 +304,8 @@ async function runCompactionHooks(params: { sessionKey?: string; messageProvider
|
||||
|
||||
beforeAll(async () => {
|
||||
const loaded = await loadCompactHooksHarness();
|
||||
compactEmbeddedAgentSessionDirect = loaded.compactEmbeddedAgentSessionDirect;
|
||||
compactEmbeddedAgentSessionDirect = (params) =>
|
||||
loaded.compactEmbeddedAgentSessionDirect({ agentId: "main", ...params });
|
||||
compactEmbeddedAgentSession = loaded.compactEmbeddedAgentSession;
|
||||
compactTesting = loaded.testing;
|
||||
onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate;
|
||||
|
||||
@@ -416,6 +416,7 @@ export const mockedMarkAuthProfileSuccess = vi.fn(async () => {});
|
||||
const mockedShouldPreferExplicitConfigApiKeyAuth = vi.fn(() => false);
|
||||
|
||||
export const overflowBaseRunParams = {
|
||||
agentId: "main",
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
@@ -1003,7 +1004,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
|
||||
});
|
||||
|
||||
const { runEmbeddedAgent } = await import("./run.js");
|
||||
return { runEmbeddedAgent };
|
||||
return {
|
||||
runEmbeddedAgent: (params) =>
|
||||
runEmbeddedAgent({ ...params, agentId: params.agentId ?? "main" }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Move one-time runner compilation out of individual behavior timings. */
|
||||
|
||||
@@ -2524,6 +2524,7 @@ describe("embedded attempt session lock lifecycle", () => {
|
||||
},
|
||||
async () =>
|
||||
await appendExactAssistantMessageToSessionTranscript({
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
storePath,
|
||||
message: {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// Verifies exec host, sandbox, and approval-default resolution for embedded agents.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import * as execApprovals from "../infra/exec-approvals.js";
|
||||
import { resolveExecDefaults, resolveNodeExecEligibility } from "./exec-defaults.js";
|
||||
|
||||
function withDefaultAgent(config: OpenClawConfig): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
agents: { ...config.agents, list: [{ id: "main", default: true }] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveExecDefaults", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -16,13 +24,13 @@ describe("resolveExecDefaults", () => {
|
||||
it("does not advertise node routing when exec host is pinned to gateway", () => {
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: false,
|
||||
}).canRequestNode,
|
||||
).toBe(false);
|
||||
@@ -30,13 +38,13 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
it("does not advertise node routing when exec host is auto and sandbox is available", () => {
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
@@ -47,13 +55,13 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
it("keeps node routing available when exec host is auto without sandbox", () => {
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: false,
|
||||
});
|
||||
|
||||
@@ -68,13 +76,13 @@ describe("resolveExecDefaults", () => {
|
||||
} as SessionEntry;
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "gateway",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sessionEntry,
|
||||
sandboxAvailable: false,
|
||||
}).canRequestNode,
|
||||
@@ -83,13 +91,13 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
it("uses host approval defaults for gateway when exec policy is unset", () => {
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: false,
|
||||
});
|
||||
|
||||
@@ -102,13 +110,13 @@ describe("resolveExecDefaults", () => {
|
||||
|
||||
it("keeps sandbox deny by default when auto resolves to sandbox", () => {
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
@@ -130,13 +138,13 @@ describe("resolveExecDefaults", () => {
|
||||
});
|
||||
|
||||
const defaults = resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: true,
|
||||
});
|
||||
|
||||
@@ -151,13 +159,13 @@ describe("resolveExecDefaults", () => {
|
||||
it("maps normalized auto mode to allowlist plus on-miss approvals", () => {
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
@@ -181,13 +189,13 @@ describe("resolveExecDefaults", () => {
|
||||
// mapping so persisted host policy remains the final safety boundary.
|
||||
expect(
|
||||
resolveExecDefaults({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "auto",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
sandboxAvailable: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
@@ -216,6 +224,7 @@ describe("resolveExecDefaults", () => {
|
||||
mode: "full",
|
||||
},
|
||||
},
|
||||
agents: { list: [{ id: "agent-a", default: true }] },
|
||||
},
|
||||
agentId: "agent-a",
|
||||
sandboxAvailable: false,
|
||||
@@ -240,6 +249,7 @@ describe("resolveExecDefaults", () => {
|
||||
list: [
|
||||
{
|
||||
id: "agent-a",
|
||||
default: true,
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "full",
|
||||
@@ -272,6 +282,7 @@ describe("resolveExecDefaults", () => {
|
||||
list: [
|
||||
{
|
||||
id: "agent-a",
|
||||
default: true,
|
||||
tools: {
|
||||
exec: {
|
||||
mode: "allowlist",
|
||||
@@ -294,7 +305,7 @@ describe("resolveExecDefaults", () => {
|
||||
it("blocks node skill eligibility for deny policy and preserves node bindings", () => {
|
||||
expect(
|
||||
resolveNodeExecEligibility({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
tools: {
|
||||
exec: {
|
||||
host: "node",
|
||||
@@ -302,7 +313,7 @@ describe("resolveExecDefaults", () => {
|
||||
node: "build-mac",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toEqual({ canExec: false, node: "build-mac" });
|
||||
});
|
||||
@@ -310,10 +321,10 @@ describe("resolveExecDefaults", () => {
|
||||
it("blocks node skill eligibility when the gateway denies system.run", () => {
|
||||
expect(
|
||||
resolveNodeExecEligibility({
|
||||
cfg: {
|
||||
cfg: withDefaultAgent({
|
||||
gateway: { nodes: { commands: { deny: [" system.run "] } } },
|
||||
tools: { exec: { host: "node", mode: "full" } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toEqual({ canExec: false });
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ function resolveExecConfigState(params: {
|
||||
execOverrides?: ExecPolicyOverrides;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
scope?: { kind: "defaults" };
|
||||
}): {
|
||||
cfg: OpenClawConfig;
|
||||
host: ExecTarget;
|
||||
@@ -82,11 +83,13 @@ function resolveExecConfigState(params: {
|
||||
} {
|
||||
const cfg = params.cfg ?? {};
|
||||
const resolvedAgentId =
|
||||
params.agentId ??
|
||||
resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
params.scope?.kind === "defaults"
|
||||
? undefined
|
||||
: (params.agentId ??
|
||||
resolveSessionAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
config: cfg,
|
||||
}));
|
||||
const globalExec = cfg.tools?.exec;
|
||||
const agentExec = resolvedAgentId
|
||||
? resolveAgentConfig(cfg, resolvedAgentId)?.tools?.exec
|
||||
@@ -132,6 +135,8 @@ export function resolveExecDefaults(params: {
|
||||
execOverrides?: ExecPolicyOverrides;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
/** Resolve agents.defaults/tools.exec without applying any roster entry override. */
|
||||
scope?: { kind: "defaults" };
|
||||
sandboxAvailable?: boolean;
|
||||
elevatedRequested?: boolean;
|
||||
}): {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { isStrictAgenticExecutionContractActive } from "./execution-contract.js"
|
||||
describe("isStrictAgenticExecutionContractActive", () => {
|
||||
const supportedProvider = "openai";
|
||||
const unsupportedProvider = "anthropic";
|
||||
const emptyConfig: OpenClawConfig = {};
|
||||
const emptyConfig: OpenClawConfig = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
};
|
||||
|
||||
describe("supported provider + model detection", () => {
|
||||
it("auto-activates on bare gpt-5 model ids", () => {
|
||||
@@ -136,6 +138,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
|
||||
it("honors explicit strict-agentic on the supported lane", () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
embeddedAgent: {
|
||||
executionContract: "strict-agentic",
|
||||
@@ -155,6 +158,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
|
||||
it("honors explicit default opt-out even on the supported lane", () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
embeddedAgent: {
|
||||
executionContract: "default",
|
||||
@@ -174,6 +178,7 @@ describe("isStrictAgenticExecutionContractActive", () => {
|
||||
it("collapses explicit strict-agentic to default on an unsupported lane", () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
embeddedAgent: {
|
||||
executionContract: "strict-agentic",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// Covers config scanning for agent harness runtime requirements.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "./harness-runtimes.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes as collectConfiguredAgentHarnessRuntimesBase } from "./harness-runtimes.js";
|
||||
|
||||
function collectConfiguredAgentHarnessRuntimes(
|
||||
config: OpenClawConfig,
|
||||
options?: Parameters<typeof collectConfiguredAgentHarnessRuntimesBase>[1],
|
||||
) {
|
||||
return collectConfiguredAgentHarnessRuntimesBase(
|
||||
migratePersistedImplicitMainRoster(config).config as OpenClawConfig,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
describe("collectConfiguredAgentHarnessRuntimes", () => {
|
||||
it("requires Codex for selectable default OpenAI agent models", () => {
|
||||
@@ -100,7 +111,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => {
|
||||
expect(collectConfiguredAgentHarnessRuntimes(config)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores malformed agents.list while scanning best-effort config", () => {
|
||||
it("ignores a malformed legacy list when canonical entries are available", () => {
|
||||
// Runtime collection is diagnostic/setup support, so malformed optional
|
||||
// agent lists should not hide valid defaults-level runtime requirements.
|
||||
const config = {
|
||||
@@ -112,6 +123,7 @@ describe("collectConfiguredAgentHarnessRuntimes", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
entries: { main: { default: true } },
|
||||
list: {
|
||||
ops: {
|
||||
id: "ops",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { OPENCLAW_AGENT_RUNTIME_ID, isDefaultAgentRuntimeId } from "./agent-runtime-id.js";
|
||||
import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js";
|
||||
import { listAgentEntries } from "./agent-scope-config.js";
|
||||
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
|
||||
|
||||
// Harness runtime discovery feeds plugin preloading/setup. Only plugin runtimes
|
||||
@@ -115,7 +116,7 @@ function pushConfiguredModelRuntimeIds(config: OpenClawConfig, runtimes: Set<str
|
||||
}
|
||||
};
|
||||
pushModelMapRuntimeIds(config.agents?.defaults?.models);
|
||||
const agents = Array.isArray(config.agents?.list) ? config.agents.list : [];
|
||||
const agents = listAgentEntries(config);
|
||||
for (const agent of agents) {
|
||||
pushModelMapRuntimeIds(isRecord(agent) ? agent.models : undefined);
|
||||
}
|
||||
@@ -152,10 +153,7 @@ function pushConfiguredAgentModelRuntimeIds(
|
||||
pushModelRefs(defaultsModelRefs);
|
||||
pushModelMapRefs(config.agents?.defaults?.models);
|
||||
|
||||
if (!Array.isArray(config.agents?.list)) {
|
||||
return;
|
||||
}
|
||||
for (const agent of config.agents.list) {
|
||||
for (const agent of listAgentEntries(config)) {
|
||||
if (!isRecord(agent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEng
|
||||
const sessionParams = {
|
||||
sessionIdUsed: "session-1",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile: "sessions/main.jsonl",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { resolveAgentHarnessPolicy } from "./policy.js";
|
||||
import { resolveAgentHarnessPolicy as resolveAgentHarnessPolicyBase } from "./policy.js";
|
||||
|
||||
function resolveAgentHarnessPolicy(
|
||||
params: Parameters<typeof resolveAgentHarnessPolicyBase>[0],
|
||||
): ReturnType<typeof resolveAgentHarnessPolicyBase> {
|
||||
return resolveAgentHarnessPolicyBase({
|
||||
...params,
|
||||
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function openAIProviderConfig(overrides: Record<string, unknown>): OpenClawConfig {
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { runWithAgentRingZeroTools } from "../agent-tools.ring-zero-context.js";
|
||||
import { createStubTool } from "../test-helpers/agent-tool-stubs.js";
|
||||
@@ -9,7 +10,16 @@ import {
|
||||
TOOL_SEARCH_RAW_TOOL_NAME,
|
||||
} from "../tool-search.js";
|
||||
import { testing } from "../tool-search.test-support.js";
|
||||
import { createAgentHarnessToolSurfaceRuntime } from "./tool-surface-bridge.js";
|
||||
import { createAgentHarnessToolSurfaceRuntime as createAgentHarnessToolSurfaceRuntimeBase } from "./tool-surface-bridge.js";
|
||||
|
||||
function createAgentHarnessToolSurfaceRuntime(
|
||||
params: Parameters<typeof createAgentHarnessToolSurfaceRuntimeBase>[0],
|
||||
): ReturnType<typeof createAgentHarnessToolSurfaceRuntimeBase> {
|
||||
return createAgentHarnessToolSurfaceRuntimeBase({
|
||||
...params,
|
||||
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function tools(names: string[]) {
|
||||
return names.map(createStubTool);
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { heartbeat: {} },
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
@@ -27,6 +28,7 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => {
|
||||
every: "0m",
|
||||
},
|
||||
},
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("internal session effects", () => {
|
||||
delivery: { kind: "internal" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(listSessionEntries({ storePath })).toEqual([]);
|
||||
expect(listSessionEntries({ agentId: "main", storePath })).toEqual([]);
|
||||
await expect(loadTranscriptEvents(target)).resolves.toEqual([
|
||||
expect.objectContaining({ id: target.sessionId, type: "session" }),
|
||||
]);
|
||||
@@ -173,7 +173,7 @@ describe("internal session effects", () => {
|
||||
type: "message",
|
||||
}),
|
||||
);
|
||||
expect(listSessionEntries({ storePath })).toEqual([
|
||||
expect(listSessionEntries({ agentId: "main", storePath })).toEqual([
|
||||
expect.objectContaining({ sessionKey: source.sessionKey }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ describe("local model lean tool filtering", () => {
|
||||
it("keeps explicitly preserved tools when lean mode is enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
experimental: {
|
||||
localModelLean: true,
|
||||
@@ -98,6 +99,7 @@ describe("local model lean tool filtering", () => {
|
||||
it("keeps image understanding while trimming optional media production tools", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
experimental: {
|
||||
localModelLean: true,
|
||||
@@ -136,6 +138,7 @@ describe("local model lean tool filtering", () => {
|
||||
it("does not treat wildcard preservation as disabling lean mode", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
experimental: {
|
||||
localModelLean: true,
|
||||
@@ -155,7 +158,10 @@ describe("local model lean tool filtering", () => {
|
||||
|
||||
it("matches wildcard preservation without treating a bare wildcard as an override", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: { defaults: { experimental: { localModelLean: true } } },
|
||||
agents: {
|
||||
defaults: { experimental: { localModelLean: true } },
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
filterLocalModelLeanTools({
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type DeliveryContext,
|
||||
} from "../utils/delivery-context.shared.js";
|
||||
import { isDeliverableMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js";
|
||||
import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js";
|
||||
import {
|
||||
@@ -99,7 +99,10 @@ export function resolveRestartRecoveryResumeBlockReason(params: {
|
||||
return "pre-hook recovery runtime config is unavailable";
|
||||
}
|
||||
try {
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(
|
||||
params.sessionKey,
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.cfg,
|
||||
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
|
||||
|
||||
@@ -2318,6 +2318,7 @@ describe("main-session-restart-recovery", () => {
|
||||
]);
|
||||
|
||||
const result = await retryRestartAbortedMainSessionRecoveryAfterOwnerRelease({
|
||||
cfg: { agents: { entries: { main: { default: true } } } },
|
||||
expectedSessionId: "legacy-session",
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
@@ -2327,7 +2328,7 @@ describe("main-session-restart-recovery", () => {
|
||||
expect(callGateway).toHaveBeenCalledOnce();
|
||||
expect(gatewayParams()).toMatchObject({
|
||||
expectedExistingSessionId: "legacy-session",
|
||||
sessionKey: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
expect(
|
||||
sessionAccessor.loadExactSessionEntry({ sessionKey: "main", storePath })?.entry,
|
||||
@@ -2562,7 +2563,7 @@ describe("main-session-restart-recovery", () => {
|
||||
.mockResolvedValueOnce({ runId: "run-resumed" });
|
||||
|
||||
scheduleRestartAbortedMainSessionRecovery({
|
||||
cfg: {},
|
||||
cfg: { agents: { entries: { main: { default: true } } } },
|
||||
delayMs: 0,
|
||||
maxRetries: 1,
|
||||
stateDir: tmpDir,
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "../sessions/session-lifecycle-admission.js";
|
||||
import { buildRunUserTurnIdempotencyKey } from "../sessions/user-turn-transcript.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.shared.js";
|
||||
import { resolveDefaultAgentId } from "./agent-scope-config.js";
|
||||
import { isAnnounceRunId } from "./announce-idempotency.js";
|
||||
import { CODE_MODE_EXEC_TOOL_NAME, CODE_MODE_WAIT_TOOL_NAME } from "./code-mode-control-tools.js";
|
||||
import {
|
||||
@@ -1232,6 +1233,7 @@ type RecoveryCheckpointCompletion =
|
||||
| { outcome: "unsafe-transcript"; reason: string };
|
||||
|
||||
async function markSessionCompletedAfterRecoveryCheckpoint(params: {
|
||||
agentId: string;
|
||||
entry: SessionEntry;
|
||||
messages: readonly unknown[];
|
||||
reason: "delivered-terminal" | "delivered-terminal-receipt" | "handled-silent";
|
||||
@@ -1390,7 +1392,7 @@ async function markSessionCompletedAfterRecoveryCheckpoint(params: {
|
||||
};
|
||||
const persisted = await persistSessionTranscriptTurn(
|
||||
{
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
agentId: params.agentId,
|
||||
sessionId: params.entry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
@@ -1497,12 +1499,13 @@ async function sendUnresumableSessionNotice(params: {
|
||||
}
|
||||
|
||||
async function writeUnresumableSessionNotice(params: {
|
||||
agentId: string;
|
||||
entry: SessionEntry;
|
||||
sessionKey: string;
|
||||
storePath: string;
|
||||
}): Promise<boolean> {
|
||||
const result = await appendAssistantMessageToSessionTranscript({
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedSessionId: params.entry.sessionId,
|
||||
expectedSessionState: {
|
||||
@@ -1554,6 +1557,10 @@ async function failUnresumableMainSession(params: {
|
||||
if (
|
||||
!deliveryContext &&
|
||||
!(await writeUnresumableSessionNotice({
|
||||
agentId: resolveAgentIdFromSessionKey(
|
||||
params.sessionKey,
|
||||
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
|
||||
),
|
||||
entry: params.entry,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
@@ -1711,6 +1718,10 @@ async function recoverStore(params: {
|
||||
a.sessionKey.localeCompare(b.sessionKey),
|
||||
)) {
|
||||
let entry = loadedEntry;
|
||||
const agentId = resolveAgentIdFromSessionKey(
|
||||
sessionKey,
|
||||
params.cfg ? resolveDefaultAgentId(params.cfg) : undefined,
|
||||
);
|
||||
if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) {
|
||||
continue;
|
||||
}
|
||||
@@ -1899,7 +1910,7 @@ async function recoverStore(params: {
|
||||
try {
|
||||
messages = await readSessionMessagesAsync(
|
||||
{
|
||||
agentId: resolveAgentIdFromSessionKey(sessionKey),
|
||||
agentId,
|
||||
sessionEntry: entry,
|
||||
sessionId: entry.sessionId,
|
||||
sessionKey,
|
||||
@@ -2000,6 +2011,7 @@ async function recoverStore(params: {
|
||||
);
|
||||
if (resumePolicy.action === "complete") {
|
||||
const completion = await markSessionCompletedAfterRecoveryCheckpoint({
|
||||
agentId,
|
||||
entry,
|
||||
messages,
|
||||
reason: resumePolicy.reason,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { modelKey } from "../shared/model-key.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
|
||||
type ModelExtraParamSources = {
|
||||
defaultParams?: Record<string, unknown>;
|
||||
@@ -28,9 +29,10 @@ export function resolveModelExtraParamSources(params: {
|
||||
? (configuredModels?.[canonicalKey]?.params ??
|
||||
(legacyKey ? configuredModels?.[legacyKey]?.params : undefined))
|
||||
: undefined;
|
||||
const agentParams = params.agentId
|
||||
? params.config?.agents?.list?.find((agent) => agent.id === params.agentId)?.params
|
||||
: undefined;
|
||||
const agentParams =
|
||||
params.agentId && params.config
|
||||
? resolveAgentConfig(params.config, params.agentId)?.params
|
||||
: undefined;
|
||||
return { defaultParams, modelParams, agentParams };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
// Covers model runtime policy precedence and private QA runtime overrides.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
|
||||
import { resolveModelRuntimePolicy } from "./model-runtime-policy.js";
|
||||
import { resolveModelRuntimePolicy as resolveModelRuntimePolicyBase } from "./model-runtime-policy.js";
|
||||
|
||||
const ORIGINAL_BUILD_PRIVATE_QA = process.env.OPENCLAW_BUILD_PRIVATE_QA;
|
||||
const ORIGINAL_QA_FORCE_RUNTIME = process.env.OPENCLAW_QA_FORCE_RUNTIME;
|
||||
|
||||
function resolveModelRuntimePolicy(
|
||||
params: Parameters<typeof resolveModelRuntimePolicyBase>[0],
|
||||
): ReturnType<typeof resolveModelRuntimePolicyBase> {
|
||||
return resolveModelRuntimePolicyBase({
|
||||
...params,
|
||||
config: migratePersistedImplicitMainRoster(params.config).config as OpenClawConfig,
|
||||
});
|
||||
}
|
||||
|
||||
const createModelConfig = (
|
||||
agentRuntimeId: string,
|
||||
modelId = "qwen-local",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
|
||||
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
|
||||
import { getActivePluginRegistryWorkspaceDirFromState } from "../plugins/runtime-state.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { resolveAgentConfig } from "./agent-scope-config.js";
|
||||
import { resolveConfiguredProviderFallback } from "./configured-provider-fallback.js";
|
||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { findModelCatalogEntry } from "./model-catalog-lookup.js";
|
||||
@@ -132,10 +132,7 @@ function createModelManifestPluginContext(params: {
|
||||
function listModelAliasCandidates(cfg: OpenClawConfig, agentId?: string): ModelAliasCandidate[] {
|
||||
const modelMaps = [cfg.agents?.defaults?.models];
|
||||
if (agentId) {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const agentModels = cfg.agents?.list?.find(
|
||||
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
|
||||
)?.models;
|
||||
const agentModels = resolveAgentConfig(cfg, agentId)?.models;
|
||||
modelMaps.push(agentModels);
|
||||
}
|
||||
return modelMaps.flatMap((models) =>
|
||||
@@ -1499,10 +1496,7 @@ export function resolveConfiguredModelPolicyAllow(params: {
|
||||
}): { refs: readonly string[]; configPath: string | null; repairConfigPath: string } {
|
||||
const defaults = params.cfg?.agents?.defaults;
|
||||
if (params.agentId) {
|
||||
const normalizedAgentId = normalizeAgentId(params.agentId);
|
||||
const agent = params.cfg?.agents?.list?.find(
|
||||
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
|
||||
);
|
||||
const agent = params.cfg ? resolveAgentConfig(params.cfg, params.agentId) : undefined;
|
||||
const agentPolicy = agent?.modelPolicy;
|
||||
if (hasExplicitModelPolicyAllow(agentPolicy)) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
function computerTool(modelHasVision?: boolean) {
|
||||
return createOpenClawTools({ modelHasVision }).find((tool) => tool.name === "computer");
|
||||
return createOpenClawTools({
|
||||
modelHasVision,
|
||||
config: { agents: { entries: { main: { default: true } } } },
|
||||
}).find((tool) => tool.name === "computer");
|
||||
}
|
||||
|
||||
describe("computer tool vision gating", () => {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
function mobileUiTool(modelHasVision?: boolean) {
|
||||
return createOpenClawTools({ modelHasVision }).find((tool) => tool.name === "mobile_ui");
|
||||
return createOpenClawTools({
|
||||
config: { agents: { entries: { main: { default: true } } } },
|
||||
modelHasVision,
|
||||
}).find((tool) => tool.name === "mobile_ui");
|
||||
}
|
||||
|
||||
describe("mobile UI tool registration", () => {
|
||||
|
||||
@@ -4,11 +4,16 @@ import { createOpenClawCodingTools } from "./agent-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
function toolNames(options: NonNullable<Parameters<typeof createOpenClawTools>[0]>) {
|
||||
const config = options.config ?? {};
|
||||
return createOpenClawTools({
|
||||
disableMessageTool: true,
|
||||
disablePluginTools: true,
|
||||
wrapBeforeToolCallHook: false,
|
||||
...options,
|
||||
config: {
|
||||
...config,
|
||||
agents: config.agents ?? { entries: { main: { default: true } } },
|
||||
},
|
||||
}).map((tool) => tool.name);
|
||||
}
|
||||
|
||||
@@ -29,7 +34,12 @@ describe("openclaw-tools Swarm gating", () => {
|
||||
...base,
|
||||
config: {
|
||||
tools: { swarm: false },
|
||||
agents: { list: [{ id: "worker", tools: { swarm: true } }] },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "worker", tools: { swarm: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toContain("agents_wait");
|
||||
@@ -38,7 +48,12 @@ describe("openclaw-tools Swarm gating", () => {
|
||||
...base,
|
||||
config: {
|
||||
tools: { swarm: true },
|
||||
agents: { list: [{ id: "worker", tools: { swarm: false } }] },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "worker", tools: { swarm: false } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).not.toContain("agents_wait");
|
||||
@@ -64,7 +79,10 @@ describe("openclaw-tools Swarm gating", () => {
|
||||
const names = createOpenClawCodingTools({
|
||||
sessionKey: "agent:worker:subagent:child",
|
||||
runId: "collector-run",
|
||||
config: { tools: { allow: ["read"], swarm: true } },
|
||||
config: {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
tools: { allow: ["read"], swarm: true },
|
||||
},
|
||||
swarmCollector: true,
|
||||
swarmOutputSchema: { type: "object", properties: { answer: { type: "string" } } },
|
||||
}).map((tool) => tool.name);
|
||||
@@ -78,7 +96,10 @@ describe("openclaw-tools Swarm gating", () => {
|
||||
const names = createOpenClawCodingTools({
|
||||
sessionKey: "agent:worker:subagent:child",
|
||||
runId: "collector-run",
|
||||
config: { tools: { swarm: true } },
|
||||
config: {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
tools: { swarm: true },
|
||||
},
|
||||
swarmCollector: true,
|
||||
}).map((tool) => tool.name);
|
||||
|
||||
@@ -89,7 +110,10 @@ describe("openclaw-tools Swarm gating", () => {
|
||||
const names = createOpenClawCodingTools({
|
||||
sessionKey: "agent:worker:main",
|
||||
runId: "collector-run",
|
||||
config: { tools: { swarm: true } },
|
||||
config: {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
tools: { swarm: true },
|
||||
},
|
||||
swarmCollector: true,
|
||||
}).map((tool) => tool.name);
|
||||
|
||||
|
||||
@@ -14,8 +14,20 @@ import { createUpdatePlanTool } from "./tools/update-plan-tool.js";
|
||||
type UpdatePlanGatingParams = Parameters<typeof shouldIncludeUpdatePlanToolForOpenClawTools>[0];
|
||||
type CreateOpenClawToolsOptions = NonNullable<Parameters<typeof createOpenClawTools>[0]>;
|
||||
|
||||
function withDefaultRoster(config: OpenClawConfig | undefined): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
agents: config?.agents ?? { entries: { main: { default: true } } },
|
||||
};
|
||||
}
|
||||
|
||||
function expectUpdatePlanEnabled(params: UpdatePlanGatingParams, expected: boolean): void {
|
||||
expect(shouldIncludeUpdatePlanToolForOpenClawTools(params)).toBe(expected);
|
||||
expect(
|
||||
shouldIncludeUpdatePlanToolForOpenClawTools({
|
||||
...params,
|
||||
config: withDefaultRoster(params.config),
|
||||
}),
|
||||
).toBe(expected);
|
||||
}
|
||||
|
||||
function toolNames(tools: ReturnType<typeof createOpenClawTools>): string[] {
|
||||
@@ -25,7 +37,7 @@ function toolNames(tools: ReturnType<typeof createOpenClawTools>): string[] {
|
||||
function createFastToolNames(options: CreateOpenClawToolsOptions): string[] {
|
||||
// Disable unrelated dynamic surfaces so registration assertions stay deterministic.
|
||||
return toolNames(
|
||||
createOpenClawTools({
|
||||
createTestOpenClawTools({
|
||||
disableMessageTool: true,
|
||||
disablePluginTools: true,
|
||||
wrapBeforeToolCallHook: false,
|
||||
@@ -34,6 +46,13 @@ function createFastToolNames(options: CreateOpenClawToolsOptions): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function createTestOpenClawTools(options: CreateOpenClawToolsOptions = {}) {
|
||||
return createOpenClawTools({
|
||||
...options,
|
||||
config: withDefaultRoster(options.config),
|
||||
});
|
||||
}
|
||||
|
||||
function expectToolNamed(
|
||||
tools: ReturnType<typeof createOpenClawTools>,
|
||||
name: string,
|
||||
@@ -127,11 +146,11 @@ describe("openclaw-tools update_plan gating", () => {
|
||||
});
|
||||
|
||||
it("wraps constructed tools with before-tool-call hooks by default", () => {
|
||||
const tools = createOpenClawTools({
|
||||
const tools = createTestOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
});
|
||||
const unwrappedTools = createOpenClawTools({
|
||||
const unwrappedTools = createTestOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
wrapBeforeToolCallHook: false,
|
||||
@@ -145,7 +164,7 @@ describe("openclaw-tools update_plan gating", () => {
|
||||
|
||||
it("keeps message tool in embedded message-tool-only completions", () => {
|
||||
setEmbeddedMode(true);
|
||||
const tools = createOpenClawTools({
|
||||
const tools = createTestOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
wrapBeforeToolCallHook: false,
|
||||
@@ -220,18 +239,18 @@ describe("openclaw-tools update_plan gating", () => {
|
||||
|
||||
it("keeps explicitly allowed message tool in embedded completions", () => {
|
||||
setEmbeddedMode(true);
|
||||
const fromRuntimeAllowlist = createOpenClawTools({
|
||||
const fromRuntimeAllowlist = createTestOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
pluginToolAllowlist: ["message"],
|
||||
wrapBeforeToolCallHook: false,
|
||||
});
|
||||
const fromGlobalAlsoAllow = createOpenClawTools({
|
||||
const fromGlobalAlsoAllow = createTestOpenClawTools({
|
||||
config: { tools: { profile: "minimal", alsoAllow: ["message"] } } as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
wrapBeforeToolCallHook: false,
|
||||
});
|
||||
const denied = createOpenClawTools({
|
||||
const denied = createTestOpenClawTools({
|
||||
config: {} as OpenClawConfig,
|
||||
disablePluginTools: true,
|
||||
pluginToolAllowlist: ["message"],
|
||||
|
||||
@@ -316,6 +316,21 @@ describe("resolveRequesterToolPolicies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a trusted internal handoff has no configuration", () => {
|
||||
expect(() =>
|
||||
resolveRequesterToolPolicies({
|
||||
agentId: "ops",
|
||||
sessionKey: "agent:ops:main",
|
||||
trustedInternalHandoff: true,
|
||||
inputProvenance: {
|
||||
kind: "inter_session",
|
||||
sourceSessionKey: "agent:ops:subagent:child",
|
||||
sourceTool: "subagent_announce",
|
||||
},
|
||||
}),
|
||||
).toThrow("Trusted internal handoff policy resolution requires configuration.");
|
||||
});
|
||||
|
||||
it("restores a verified completion handoff to a distinct immutable completion owner", async () => {
|
||||
const controllerSessionKey = "agent:main:discord:direct:alice";
|
||||
const completionOwnerSessionKey = "agent:main:main";
|
||||
|
||||
@@ -120,6 +120,9 @@ function resolveDelegatedPolicy(
|
||||
) {
|
||||
return { delegated: false };
|
||||
}
|
||||
if (!params.config) {
|
||||
throw new Error("Trusted internal handoff policy resolution requires configuration.");
|
||||
}
|
||||
const targetSessionKey = resolveRequesterStoreKey(params.config, params.sessionKey);
|
||||
let currentSessionKey = resolveRequesterStoreKey(params.config, provenance.sourceSessionKey);
|
||||
const visited = new Set<string>();
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("sandbox SSH secret owner", () => {
|
||||
it("rejects an unmaterialized inherited ref without active degraded-owner state", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
assertRuntimeSandboxSecretOwnerAvailable,
|
||||
runtimeSandboxSecretOwnerId,
|
||||
} from "../../secrets/runtime-sandbox-secret-owner.js";
|
||||
import { resolveAgentConfig } from "../agent-scope-config.js";
|
||||
import { listAgentEntriesWithSource, resolveAgentConfig } from "../agent-scope-config.js";
|
||||
import type { SandboxScope } from "./types.js";
|
||||
|
||||
const SSH_SECRET_KEYS = ["identityData", "certificateData", "knownHostsData"] as const;
|
||||
@@ -32,10 +32,10 @@ export function assertSshSandboxSecretOwnerAvailable(params: {
|
||||
? resolveAgentConfig(params.config, params.agentId)?.sandbox?.ssh
|
||||
: undefined;
|
||||
const normalizedAgentId = params.agentId ? normalizeAgentId(params.agentId) : undefined;
|
||||
const agentIndex = normalizedAgentId
|
||||
? params.config.agents?.list?.findIndex(
|
||||
(entry) => normalizeAgentId(entry?.id) === normalizedAgentId,
|
||||
)
|
||||
const agentSource = normalizedAgentId
|
||||
? listAgentEntriesWithSource(params.config).find(
|
||||
({ entry }) => normalizeAgentId(entry.id) === normalizedAgentId,
|
||||
)?.source
|
||||
: undefined;
|
||||
const unresolved: Array<{ path: string; refKey: string }> = [];
|
||||
for (const key of SSH_SECRET_KEYS) {
|
||||
@@ -47,8 +47,10 @@ export function assertSshSandboxSecretOwnerAvailable(params: {
|
||||
}
|
||||
unresolved.push({
|
||||
path:
|
||||
usesAgentValue && agentIndex !== undefined && agentIndex >= 0
|
||||
? `agents.list.${agentIndex}.sandbox.ssh.${key}`
|
||||
usesAgentValue && agentSource
|
||||
? agentSource.kind === "entries"
|
||||
? `agents.entries.${agentSource.key}.sandbox.ssh.${key}`
|
||||
: `agents.list.${agentSource.index}.sandbox.ssh.${key}`
|
||||
: `agents.defaults.sandbox.ssh.${key}`,
|
||||
refKey: secretRefKey(ref),
|
||||
});
|
||||
|
||||
@@ -2,12 +2,46 @@
|
||||
// guidance for sandboxed agent sessions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveSandboxConfigForAgent } from "./config.js";
|
||||
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
|
||||
import { resolveSandboxConfigForAgent as resolveSandboxConfigForAgentBase } from "./config.js";
|
||||
import {
|
||||
formatSandboxToolPolicyBlockedMessage,
|
||||
resolveSandboxRuntimeStatus,
|
||||
formatSandboxToolPolicyBlockedMessage as formatSandboxToolPolicyBlockedMessageBase,
|
||||
resolveSandboxRuntimeStatus as resolveSandboxRuntimeStatusBase,
|
||||
} from "./runtime-status.js";
|
||||
import { isToolAllowed, resolveSandboxToolPolicyForAgent } from "./tool-policy.js";
|
||||
import {
|
||||
isToolAllowed,
|
||||
resolveSandboxToolPolicyForAgent as resolveSandboxToolPolicyForAgentBase,
|
||||
} from "./tool-policy.js";
|
||||
|
||||
function loadedConfig(config: OpenClawConfig | undefined): OpenClawConfig {
|
||||
return migratePersistedImplicitMainRoster(config ?? {}).config as OpenClawConfig;
|
||||
}
|
||||
|
||||
function resolveSandboxConfigForAgent(config: OpenClawConfig, agentId: string) {
|
||||
return resolveSandboxConfigForAgentBase(loadedConfig(config), agentId);
|
||||
}
|
||||
|
||||
function resolveSandboxToolPolicyForAgent(config: OpenClawConfig, agentId: string) {
|
||||
return resolveSandboxToolPolicyForAgentBase(loadedConfig(config), agentId);
|
||||
}
|
||||
|
||||
function resolveSandboxRuntimeStatus(
|
||||
params: Parameters<typeof resolveSandboxRuntimeStatusBase>[0],
|
||||
) {
|
||||
return resolveSandboxRuntimeStatusBase({
|
||||
...params,
|
||||
cfg: loadedConfig(params.cfg),
|
||||
});
|
||||
}
|
||||
|
||||
function formatSandboxToolPolicyBlockedMessage(
|
||||
params: Parameters<typeof formatSandboxToolPolicyBlockedMessageBase>[0],
|
||||
) {
|
||||
return formatSandboxToolPolicyBlockedMessageBase({
|
||||
...params,
|
||||
cfg: loadedConfig(params.cfg),
|
||||
});
|
||||
}
|
||||
|
||||
describe("sandbox/tool-policy", () => {
|
||||
it("merges sandbox alsoAllow into the default sandbox allowlist", () => {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
// Verifies simple-completion model selection preserves provider, model, and profile refs.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveSimpleCompletionSelectionForAgent } from "./simple-completion-runtime.js";
|
||||
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
|
||||
import { resolveSimpleCompletionSelectionForAgent as resolveSimpleCompletionSelectionForAgentBase } from "./simple-completion-runtime.js";
|
||||
|
||||
function resolveSimpleCompletionSelectionForAgent(
|
||||
params: Parameters<typeof resolveSimpleCompletionSelectionForAgentBase>[0],
|
||||
) {
|
||||
return resolveSimpleCompletionSelectionForAgentBase({
|
||||
...params,
|
||||
cfg: migratePersistedImplicitMainRoster(params.cfg).config as OpenClawConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function requireSelection(selection: ReturnType<typeof resolveSimpleCompletionSelectionForAgent>) {
|
||||
// Narrows absent selections so each case can assert parsed provider/model fields.
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
normalizeMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
import { hasAcceptedSessionSpawn } from "./accepted-session-spawn.js";
|
||||
import { resolveDefaultAgentId } from "./agent-scope-config.js";
|
||||
import {
|
||||
collectDeliveredMediaUrls,
|
||||
collectMessagingToolDeliveredMediaUrls,
|
||||
@@ -747,7 +748,7 @@ export async function resolveSubagentCompletionOrigin(params: {
|
||||
export function loadRequesterSessionEntry(requesterSessionKey: string) {
|
||||
const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig();
|
||||
const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalKey, resolveDefaultAgentId(cfg));
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
const entry = subagentAnnounceDeliveryDeps.loadSessionEntry({
|
||||
storePath,
|
||||
@@ -759,7 +760,7 @@ export function loadRequesterSessionEntry(requesterSessionKey: string) {
|
||||
|
||||
export function loadSessionEntryByKey(sessionKey: string) {
|
||||
const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig();
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(cfg));
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
return subagentAnnounceDeliveryDeps.loadSessionEntry({
|
||||
storePath,
|
||||
@@ -1054,7 +1055,10 @@ async function deliverGeneratedMediaCompletionDirect(params: {
|
||||
sourceTool: params.sourceTool,
|
||||
internalEvents: params.internalEvents,
|
||||
});
|
||||
const agentId = resolveAgentIdFromSessionKey(params.requesterSessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(
|
||||
params.requesterSessionKey,
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
const idempotencyKey = `${params.directIdempotencyKey}:generated-media-direct`;
|
||||
try {
|
||||
await subagentAnnounceDeliveryDeps.sendMessage({
|
||||
@@ -1205,7 +1209,10 @@ async function deliverTextCompletionDirect(params: {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(params.requesterSessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(
|
||||
params.requesterSessionKey,
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
const idempotencyKey = `${params.directIdempotencyKey}:text-direct`;
|
||||
try {
|
||||
await subagentAnnounceDeliveryDeps.sendMessage({
|
||||
|
||||
@@ -7,18 +7,12 @@ import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveMainSessionKey,
|
||||
} from "../config/sessions/main-session.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
|
||||
type RequesterStoreKeyConfig = {
|
||||
session?: { mainKey?: string };
|
||||
agents?: { list?: Array<{ id?: string; default?: boolean }> };
|
||||
};
|
||||
import { resolveDefaultAgentId } from "./agent-scope-config.js";
|
||||
|
||||
/** Resolve the canonical store key for a subagent requester session. */
|
||||
export function resolveRequesterStoreKey(
|
||||
cfg: RequesterStoreKeyConfig | undefined,
|
||||
requesterSessionKey: string,
|
||||
): string {
|
||||
export function resolveRequesterStoreKey(cfg: OpenClawConfig, requesterSessionKey: string): string {
|
||||
const raw = (requesterSessionKey ?? "").trim();
|
||||
if (!raw) {
|
||||
return raw;
|
||||
@@ -33,6 +27,6 @@ export function resolveRequesterStoreKey(
|
||||
if (raw === "main" || raw === mainKey) {
|
||||
return resolveMainSessionKey(cfg);
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(raw);
|
||||
const agentId = resolveAgentIdFromSessionKey(raw, resolveDefaultAgentId(cfg));
|
||||
return `agent:${agentId}:${raw}`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import { resolveAgentToolSearchRuntimeConfig } from "./tool-search-runtime-confi
|
||||
|
||||
function createRuntimeConfigPair() {
|
||||
const sourceConfig = {
|
||||
agents: { defaults: { experimental: { localModelLean: true } } },
|
||||
agents: {
|
||||
defaults: { experimental: { localModelLean: true } },
|
||||
entries: { main: { default: true } },
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"example-plugin": {
|
||||
@@ -80,6 +83,7 @@ describe("resolveAgentToolSearchRuntimeConfig", () => {
|
||||
const { runtimeConfig, sourceConfig } = createRuntimeConfigPair();
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
const explicitConfig = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"example-plugin": { config: { marker: "explicit" } },
|
||||
@@ -92,7 +96,10 @@ describe("resolveAgentToolSearchRuntimeConfig", () => {
|
||||
});
|
||||
|
||||
it("uses the input config when no runtime snapshot exists", () => {
|
||||
const config = { tools: { toolSearch: false } } as OpenClawConfig;
|
||||
const config = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
tools: { toolSearch: false },
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(resolveAgentRuntimeToolConfig(config)).toBe(config);
|
||||
expect(resolveAgentToolSearchRuntimeConfig({ config })).toBe(config);
|
||||
|
||||
@@ -139,36 +139,6 @@ describe("agents_list tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the implicit default agent as a configured target", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: { allowAgents: ["main"] },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
|
||||
const result = await createAgentsListTool({ agentSessionKey: "agent:main:main" }).execute(
|
||||
"call",
|
||||
{},
|
||||
);
|
||||
const details = result.details as AgentListDetails;
|
||||
|
||||
expect(details).toStrictEqual({
|
||||
requester: "main",
|
||||
allowAny: false,
|
||||
agents: [
|
||||
{
|
||||
id: "main",
|
||||
name: undefined,
|
||||
configured: true,
|
||||
model: undefined,
|
||||
agentRuntime: { id: "codex", source: "implicit" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores legacy env-forced plugin runtime selections", async () => {
|
||||
// Runtime selection now comes from config/model routing, not a process-wide
|
||||
// legacy env override.
|
||||
|
||||
@@ -5,13 +5,9 @@
|
||||
*/
|
||||
import { Type } from "typebox";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveModelAgentRuntimeMetadata } from "../agent-runtime-metadata.js";
|
||||
import { listAgentIds } from "../agent-scope-config.js";
|
||||
import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { resolveAgentConfig, resolveAgentEffectiveModelPrimary } from "../agent-scope.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { resolveSubagentAllowedTargetIds } from "../subagent-target-policy.js";
|
||||
@@ -102,14 +98,14 @@ export function createAgentsListTool(opts?: {
|
||||
const requesterAgentId = normalizeAgentId(
|
||||
opts?.requesterAgentIdOverride ??
|
||||
parseAgentSessionKey(requesterInternalKey)?.agentId ??
|
||||
DEFAULT_AGENT_ID,
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
|
||||
const allowAgents =
|
||||
resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ??
|
||||
cfg?.agents?.defaults?.subagents?.allowAgents;
|
||||
|
||||
const configuredAgents = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
|
||||
const configuredAgents = listAgentEntries(cfg);
|
||||
const configuredIds = listAgentIds(cfg);
|
||||
const configuredNameMap = new Map<string, string>();
|
||||
for (const entry of configuredAgents) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createMessageTool } from "./message-tool.js";
|
||||
describe("WebChat message tool internal source reply", () => {
|
||||
it("projects a real targetless send and preserves the automatic final reply", async () => {
|
||||
const tool = createMessageTool({
|
||||
config: {},
|
||||
config: { agents: { entries: { main: { default: true } } } },
|
||||
currentChannelProvider: "webchat",
|
||||
sourceReplyDeliveryMode: "automatic",
|
||||
agentSessionKey: "agent:main:webchat:dm:dashboard",
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
DEFAULT_AGENT_ID,
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { buildAgentMainSessionKey, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveInternalSessionKey } from "./sessions-helpers.js";
|
||||
|
||||
type ResolvedStatusSessionEntry = {
|
||||
@@ -39,21 +35,21 @@ export function resolveSessionStatusEntry(params: {
|
||||
|
||||
const candidates: string[] = [keyRaw];
|
||||
if (!keyRaw.startsWith("agent:")) {
|
||||
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
|
||||
candidates.push(`agent:${params.agentId}:${keyRaw}`);
|
||||
}
|
||||
if (includeAliasFallback && internal !== keyRaw) {
|
||||
candidates.push(internal);
|
||||
}
|
||||
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
|
||||
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
|
||||
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
|
||||
const agentInternal = `agent:${params.agentId}:${internal}`;
|
||||
const agentRaw = `agent:${params.agentId}:${keyRaw}`;
|
||||
if (agentInternal !== agentRaw) {
|
||||
candidates.push(agentInternal);
|
||||
}
|
||||
}
|
||||
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
|
||||
const defaultMainKey = buildAgentMainSessionKey({
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
agentId: params.agentId,
|
||||
mainKey: params.mainKey,
|
||||
});
|
||||
if (!candidates.includes(defaultMainKey)) {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agent-scope.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
@@ -571,8 +572,10 @@ export function createSessionStatusTool(opts?: {
|
||||
sandboxed: opts?.sandboxed,
|
||||
});
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const configuredDefaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(
|
||||
opts?.agentSessionKey ?? effectiveRequesterKey,
|
||||
configuredDefaultAgentId,
|
||||
);
|
||||
const visibilityRequesterKey = (opts?.agentSessionKey ?? effectiveRequesterKey).trim();
|
||||
const usesLegacyMainAlias = alias === mainKey;
|
||||
@@ -583,7 +586,8 @@ export function createSessionStatusTool(opts?: {
|
||||
const resolveVisibilityMainSessionKey = (sessionAgentId: string) => {
|
||||
const requesterParsed = parseAgentSessionKey(visibilityRequesterKey);
|
||||
if (
|
||||
resolveAgentIdFromSessionKey(visibilityRequesterKey) === sessionAgentId &&
|
||||
resolveAgentIdFromSessionKey(visibilityRequesterKey, configuredDefaultAgentId) ===
|
||||
sessionAgentId &&
|
||||
(requesterParsed?.rest === mainKey || isLegacyMainVisibilityKey(visibilityRequesterKey))
|
||||
) {
|
||||
return visibilityRequesterKey;
|
||||
@@ -613,6 +617,7 @@ export function createSessionStatusTool(opts?: {
|
||||
};
|
||||
const visibilityGuard = await createSessionVisibilityGuard({
|
||||
action: "status",
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
requesterSessionKey: visibilityRequesterKey,
|
||||
visibility: resolveEffectiveSessionToolsVisibility({
|
||||
cfg,
|
||||
@@ -683,7 +688,10 @@ export function createSessionStatusTool(opts?: {
|
||||
};
|
||||
|
||||
if (requestedKeyInput.startsWith("agent:") && !isSemanticCurrentRequest) {
|
||||
const requestedAgentId = resolveAgentIdFromSessionKey(requestedKeyInput);
|
||||
const requestedAgentId = resolveAgentIdFromSessionKey(
|
||||
requestedKeyInput,
|
||||
configuredDefaultAgentId,
|
||||
);
|
||||
ensureAgentAccess(requestedAgentId);
|
||||
const access = visibilityGuard.check(
|
||||
normalizeVisibilityTargetSessionKey(requestedKeyInput, requestedAgentId),
|
||||
@@ -695,7 +703,7 @@ export function createSessionStatusTool(opts?: {
|
||||
|
||||
const isExplicitAgentKey = requestedKeyInput.startsWith("agent:");
|
||||
let agentId = isExplicitAgentKey
|
||||
? resolveAgentIdFromSessionKey(requestedKeyInput)
|
||||
? resolveAgentIdFromSessionKey(requestedKeyInput, configuredDefaultAgentId)
|
||||
: requesterAgentId;
|
||||
let storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
let storeScopedRequesterKey = resolveStoreScopedRequesterKey({
|
||||
@@ -738,11 +746,13 @@ export function createSessionStatusTool(opts?: {
|
||||
throw new Error("Session status visibility is restricted to the current session tree.");
|
||||
}
|
||||
// If resolution points at another agent, enforce A2A policy before switching stores.
|
||||
ensureAgentAccess(resolveAgentIdFromSessionKey(visibleSession.key));
|
||||
ensureAgentAccess(
|
||||
resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId),
|
||||
);
|
||||
resolvedViaSessionId = true;
|
||||
requestedKeyRaw = visibleSession.key;
|
||||
requestedKeyInput = requestedKeyRaw.trim();
|
||||
agentId = resolveAgentIdFromSessionKey(visibleSession.key);
|
||||
agentId = resolveAgentIdFromSessionKey(visibleSession.key, configuredDefaultAgentId);
|
||||
storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
storeScopedRequesterKey = resolveStoreScopedRequesterKey({
|
||||
requesterKey: effectiveRequesterKey,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { capArrayByJsonBytes } from "../../gateway/session-transcript-readers.js
|
||||
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
|
||||
import { redactToolPayloadText } from "../../logging/redact.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { optionalPositiveIntegerSchema } from "../schema/typebox.js";
|
||||
import {
|
||||
describeSessionsHistoryTool,
|
||||
@@ -432,6 +433,7 @@ export function createSessionsHistoryTool(opts?: {
|
||||
});
|
||||
const visibilityGuard = await createSessionVisibilityGuard({
|
||||
action: "history",
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
visibility,
|
||||
a2aPolicy,
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
// helpers, and numeric argument validation.
|
||||
import { Value } from "typebox/value";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { compactToolOutputHint } from "../tool-schema-hints.js";
|
||||
import { createSessionsListTool } from "./sessions-list-tool.js";
|
||||
|
||||
const VALID_CONFIG: OpenClawConfig = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
gatewayCall: vi.fn(),
|
||||
createAgentToAgentPolicy: vi.fn(() => ({})),
|
||||
@@ -88,7 +93,7 @@ describe("sessions-list-tool", () => {
|
||||
main: { "agent:main:main": 7, "agent:main:subagent:child": 0 },
|
||||
});
|
||||
|
||||
const result = await createSessionsListTool({ config: {} as never }).execute("call-state", {});
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("call-state", {});
|
||||
|
||||
expect(mocks.getSessionStateVersions).toHaveBeenCalledWith([
|
||||
{ sessionKey: "agent:main:main", agentId: "main" },
|
||||
@@ -107,7 +112,7 @@ describe("sessions-list-tool", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createSessionsListTool({ config: {} as never }).execute("blind", {});
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("blind", {});
|
||||
|
||||
expect(getSessionsListDetails(result).sessions?.map((session) => session.key)).toEqual([
|
||||
"agent:main:dashboard:visible",
|
||||
@@ -143,7 +148,7 @@ describe("sessions-list-tool", () => {
|
||||
mocks.getSessionStateVersions.mockReturnValue({
|
||||
main: { "agent:main:subagent:child": 4 },
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
const result = await tool.execute("contract", {});
|
||||
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
@@ -213,7 +218,7 @@ describe("sessions-list-tool", () => {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
|
||||
const result = await tool.execute("call-1", {});
|
||||
const details = getSessionsListDetails(result);
|
||||
@@ -237,14 +242,14 @@ describe("sessions-list-tool", () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await createSessionsListTool({ config: {} as never }).execute("lineage", {});
|
||||
const result = await createSessionsListTool({ config: VALID_CONFIG }).execute("lineage", {});
|
||||
|
||||
expect(getSessionsListDetails(result).sessions?.[0]?.parentSessionKey).toBe(
|
||||
"agent:main:subagent:parent",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives channels only from structurally valid group session keys", async () => {
|
||||
it("omits malformed agent keys and derives channels only from valid group keys", async () => {
|
||||
mocks.gatewayCall.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.list") {
|
||||
@@ -281,7 +286,7 @@ describe("sessions-list-tool", () => {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
|
||||
const result = await tool.execute("call-agent-scoped-channel", {});
|
||||
const details = getSessionsListDetails(result);
|
||||
@@ -291,7 +296,6 @@ describe("sessions-list-tool", () => {
|
||||
"discord",
|
||||
"matrix",
|
||||
"unknown",
|
||||
"unknown",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -321,7 +325,7 @@ describe("sessions-list-tool", () => {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
|
||||
const result = await tool.execute("call-3", {});
|
||||
const details = getSessionsListDetails(result);
|
||||
@@ -350,7 +354,7 @@ describe("sessions-list-tool", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
|
||||
const result = await tool.execute("call-archived", { archived: true });
|
||||
|
||||
@@ -375,7 +379,7 @@ describe("sessions-list-tool", () => {
|
||||
])("rejects invalid numeric parameter %o", async (params, message) => {
|
||||
// Reject before gateway dispatch so malformed limits cannot reach session
|
||||
// store queries.
|
||||
const tool = createSessionsListTool({ config: {} as never });
|
||||
const tool = createSessionsListTool({ config: VALID_CONFIG });
|
||||
|
||||
await expect(tool.execute("call-4", params)).rejects.toThrow(message);
|
||||
expect(mocks.gatewayCall).not.toHaveBeenCalled();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session
|
||||
import { deriveSessionTitle } from "../../gateway/session-utils.js";
|
||||
import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { getSessionStateVersions } from "../../sessions/session-state-events.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import {
|
||||
optionalNonNegativeIntegerSchema,
|
||||
optionalPositiveIntegerSchema,
|
||||
@@ -207,24 +208,30 @@ export function createSessionsListTool(opts?: {
|
||||
const sessions = (Array.isArray(list?.sessions) ? list.sessions : []).filter(
|
||||
(entry) => !entry || typeof entry !== "object" || !isIncognitoSessionKey(entry.key),
|
||||
);
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const stateVersions = getSessionStateVersions(
|
||||
sessions.flatMap((entry) =>
|
||||
entry && typeof entry === "object" && typeof entry.key === "string"
|
||||
? [
|
||||
{
|
||||
sessionKey: entry.key,
|
||||
agentId:
|
||||
typeof entry.agentId === "string" && entry.agentId
|
||||
? entry.agentId
|
||||
: resolveAgentIdFromSessionKey(entry.key),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
sessions.flatMap((entry) => {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.key !== "string") {
|
||||
return [];
|
||||
}
|
||||
let stateAgentId =
|
||||
typeof entry.agentId === "string" && entry.agentId ? entry.agentId : undefined;
|
||||
if (!stateAgentId) {
|
||||
try {
|
||||
stateAgentId = resolveAgentIdFromSessionKey(entry.key, defaultAgentId);
|
||||
} catch {
|
||||
// Malformed rows remain subject to the fail-closed visibility checker below,
|
||||
// but cannot participate in agent state-version lookup.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [{ sessionKey: entry.key, agentId: stateAgentId }];
|
||||
}),
|
||||
);
|
||||
const storePath = typeof list?.path === "string" ? list.path : undefined;
|
||||
const visibilityGuard = createSessionVisibilityRowChecker({
|
||||
action: "list",
|
||||
defaultAgentId,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
visibility,
|
||||
a2aPolicy,
|
||||
@@ -301,7 +308,7 @@ export function createSessionsListTool(opts?: {
|
||||
const sessionId = readStringValue(entry.sessionId);
|
||||
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
|
||||
const sessionFile = readStringValue(sessionFileRaw);
|
||||
const resolvedAgentId = resolveAgentIdFromSessionKey(key);
|
||||
const resolvedAgentId = resolveAgentIdFromSessionKey(key, defaultAgentId);
|
||||
// Version lookup keys on the store-owning agent (gateway row agentId), not the
|
||||
// key-derived agent: bare "global" keys parse to the default agent id.
|
||||
const stateVersionAgentId =
|
||||
|
||||
@@ -30,8 +30,15 @@ function createTool(params: {
|
||||
requests?: CallGatewayRequest[];
|
||||
truncated?: boolean;
|
||||
}) {
|
||||
const config = params.config ?? { tools: { sessions: { visibility: "self" } } };
|
||||
return createSessionsSearchTool({
|
||||
config: params.config ?? { tools: { sessions: { visibility: "self" } } },
|
||||
config: {
|
||||
...config,
|
||||
agents: {
|
||||
entries: { main: { default: true } },
|
||||
...(config.agents as Record<string, unknown> | undefined),
|
||||
},
|
||||
},
|
||||
agentId: params.agentId,
|
||||
agentSessionKey: params.agentSessionKey,
|
||||
sandboxed: params.sandboxed,
|
||||
@@ -273,7 +280,10 @@ describe("sessions_search tool", () => {
|
||||
|
||||
it("accepts the gateway's canonical key for the current-session alias", async () => {
|
||||
const tool = createSessionsSearchTool({
|
||||
config: { tools: { sessions: { visibility: "self" } } },
|
||||
config: {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
tools: { sessions: { visibility: "self" } },
|
||||
},
|
||||
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
|
||||
if (request.method === "sessions.list") {
|
||||
return { sessions: [], hasMore: false } as T;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { truncateUtf16Safe } from "../../utils.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { resolveSessionAgentId } from "../agent-scope.js";
|
||||
import { optionalPositiveIntegerSchema } from "../schema/typebox.js";
|
||||
import {
|
||||
@@ -386,41 +387,40 @@ export function createSessionsSearchTool(opts?: {
|
||||
sandboxed: opts?.sandboxed === true,
|
||||
});
|
||||
const a2aPolicy = createAgentToAgentPolicy(cfg);
|
||||
const defaultAgentId = resolveDefaultAgentId(cfg);
|
||||
const requesterAgentId =
|
||||
opts?.agentId ?? resolveSessionAgentId({ sessionKey: effectiveRequesterKey, config: cfg });
|
||||
const guard = await createSessionVisibilityGuard({
|
||||
action: "history",
|
||||
requesterAgentId: opts?.agentId,
|
||||
defaultAgentId,
|
||||
requesterAgentId,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
visibility,
|
||||
a2aPolicy,
|
||||
});
|
||||
const rowGuard = createSessionVisibilityRowChecker({
|
||||
action: "history",
|
||||
requesterAgentId: opts?.agentId,
|
||||
defaultAgentId,
|
||||
requesterAgentId,
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
visibility,
|
||||
a2aPolicy,
|
||||
});
|
||||
if (sessionKey) {
|
||||
const access =
|
||||
opts?.agentId && !parseAgentSessionKey(sessionKey)
|
||||
? rowGuard.check({ key: sessionKey, agentId: opts.agentId })
|
||||
: guard.check(sessionKey);
|
||||
const access = !parseAgentSessionKey(sessionKey)
|
||||
? rowGuard.check({ key: sessionKey, agentId: requesterAgentId })
|
||||
: guard.check(sessionKey);
|
||||
if (!access.allowed) {
|
||||
return jsonResult({ status: access.status, error: access.error });
|
||||
}
|
||||
}
|
||||
const requesterAgentId =
|
||||
opts?.agentId ?? resolveSessionAgentId({ sessionKey: effectiveRequesterKey, config: cfg });
|
||||
|
||||
const searchSessions = (
|
||||
sessionKey
|
||||
? [
|
||||
{
|
||||
key: sessionKey,
|
||||
access: "direct" as const,
|
||||
...(opts?.agentId && !parseAgentSessionKey(sessionKey)
|
||||
? { agentId: opts.agentId }
|
||||
: {}),
|
||||
...(!parseAgentSessionKey(sessionKey) ? { agentId: requesterAgentId } : {}),
|
||||
},
|
||||
]
|
||||
: await listVisibleSearchSessions({
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type GatewayMessageChannel,
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { listAgentIds } from "../agent-scope.js";
|
||||
import {
|
||||
type EmbeddedAgentQueueMessageOptions,
|
||||
@@ -177,7 +178,10 @@ function isConfiguredAgentMainSessionKey(params: {
|
||||
sessionKey: string;
|
||||
mainKey: string;
|
||||
}): boolean {
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const agentId = resolveAgentIdFromSessionKey(
|
||||
params.sessionKey,
|
||||
resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
return (
|
||||
params.sessionKey ===
|
||||
resolveConfiguredAgentMainSessionKey({
|
||||
@@ -217,7 +221,7 @@ async function ensureConfiguredAgentMainSession(params: {
|
||||
try {
|
||||
const createParams = {
|
||||
key: params.sessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey, resolveDefaultAgentId(params.cfg)),
|
||||
};
|
||||
if (
|
||||
params.useTrustedInProcessCreation &&
|
||||
@@ -469,7 +473,10 @@ export function createSessionsSendTool(opts?: {
|
||||
sessionKey = agentMainKey;
|
||||
}
|
||||
if (!sessionKey && labelParam) {
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(effectiveRequesterKey);
|
||||
const requesterAgentId = resolveAgentIdFromSessionKey(
|
||||
effectiveRequesterKey,
|
||||
resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const requestedAgentId = labelAgentIdParam
|
||||
? normalizeAgentId(labelAgentIdParam)
|
||||
: undefined;
|
||||
@@ -615,6 +622,7 @@ export function createSessionsSendTool(opts?: {
|
||||
}
|
||||
const visibilityGuard = await createSessionVisibilityGuard({
|
||||
action: "send",
|
||||
defaultAgentId: resolveDefaultAgentId(cfg),
|
||||
requesterSessionKey: effectiveRequesterKey,
|
||||
visibility: sessionVisibility,
|
||||
a2aPolicy,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { withAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js";
|
||||
import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveDefaultAgentId } from "../agent-scope-config.js";
|
||||
import { stringEnum } from "../schema/typebox.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readStringParam, ToolAuthorizationError, ToolInputError } from "./common.js";
|
||||
@@ -158,8 +159,12 @@ async function resolvePatchTarget(
|
||||
// the action only selects error copy. Owner gating remains separate.
|
||||
const guard = await createSessionVisibilityGuard({
|
||||
action: "status",
|
||||
defaultAgentId: resolveDefaultAgentId(context.cfg),
|
||||
requesterSessionKey: context.effectiveRequesterKey,
|
||||
requesterAgentId: resolveAgentIdFromSessionKey(context.effectiveRequesterKey),
|
||||
requesterAgentId: resolveAgentIdFromSessionKey(
|
||||
context.effectiveRequesterKey,
|
||||
resolveDefaultAgentId(context.cfg),
|
||||
),
|
||||
visibility: resolveEffectiveSessionToolsVisibility({
|
||||
cfg: context.cfg,
|
||||
sandboxed: opts.sandboxed === true,
|
||||
|
||||
@@ -5,19 +5,31 @@
|
||||
* plus the default agent workspace without duplicating agent-scope logic.
|
||||
*/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
listAgentEntries,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "./agent-scope.js";
|
||||
|
||||
/** Lists unique workspace directories for configured agents and the default agent. */
|
||||
export function listAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
const list = cfg.agents?.list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && typeof entry.id === "string") {
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
|
||||
}
|
||||
}
|
||||
for (const entry of listAgentEntries(cfg)) {
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
|
||||
}
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
/** Lists only entry-authored workspace paths without requiring a valid default marker. */
|
||||
export function listExplicitAgentWorkspaceDirs(cfg: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
for (const entry of listAgentEntries(cfg)) {
|
||||
const workspace = typeof entry.workspace === "string" ? entry.workspace.trim() : "";
|
||||
if (workspace) {
|
||||
dirs.add(resolveUserPath(workspace));
|
||||
}
|
||||
}
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
// Workspace run tests cover runtime workspace resolution from explicit input,
|
||||
// agent config, session keys, and environment fallback.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveRunWorkspaceDir } from "./workspace-run.js";
|
||||
|
||||
vi.unmock("./agent-scope-config.js");
|
||||
|
||||
describe("resolveRunWorkspaceDir", () => {
|
||||
it("resolves explicit workspace values without fallback", () => {
|
||||
const explicit = path.join(process.cwd(), "tmp", "workspace-run-explicit");
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: explicit,
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
config: { agents: { list: [{ id: "main", default: true }] } },
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(false);
|
||||
@@ -22,7 +25,7 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
it("recognizes an explicitly supplied configured workspace as canonical", () => {
|
||||
const workspaceDir = path.join(process.cwd(), "tmp", "workspace-run-canonical");
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: workspaceDir } },
|
||||
agents: { defaults: { workspace: workspaceDir }, list: [{ id: "main", default: true }] },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = resolveRunWorkspaceDir({
|
||||
@@ -41,7 +44,7 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: defaultWorkspace },
|
||||
list: [{ id: "research", workspace: researchWorkspace }],
|
||||
list: [{ id: "research", workspace: researchWorkspace, default: true }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
@@ -63,6 +66,7 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: defaultWorkspace },
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
@@ -78,19 +82,16 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
expect(result.workspaceDir).toBe(path.resolve(defaultWorkspace));
|
||||
});
|
||||
|
||||
it("falls back to built-in main workspace when config is unavailable", () => {
|
||||
it("refuses to invent an agent when config is unavailable", () => {
|
||||
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: null,
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
config: undefined,
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE_DIR: workspaceDir },
|
||||
});
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
expect(result.fallbackReason).toBe("missing");
|
||||
expect(result.agentId).toBe("main");
|
||||
expect(result.workspaceDir).toBe(path.resolve(workspaceDir));
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: null,
|
||||
sessionKey: "custom-main-key",
|
||||
config: undefined,
|
||||
env: { ...process.env, OPENCLAW_WORKSPACE_DIR: workspaceDir },
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
|
||||
});
|
||||
|
||||
it("throws for malformed agent session keys", () => {
|
||||
@@ -103,24 +104,46 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
).toThrow("Malformed agent session key");
|
||||
});
|
||||
|
||||
it("uses explicit agent id for per-agent fallback when config is unavailable", () => {
|
||||
it("requires roster config for per-agent fallback", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: "/home/runner",
|
||||
OPENCLAW_HOME: undefined,
|
||||
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
const result = resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "definitely-not-a-valid-session-key",
|
||||
agentId: "research",
|
||||
config: undefined,
|
||||
env,
|
||||
});
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
sessionKey: "definitely-not-a-valid-session-key",
|
||||
agentId: "research",
|
||||
config: undefined,
|
||||
env,
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
|
||||
});
|
||||
|
||||
expect(result.agentId).toBe("research");
|
||||
expect(result.agentIdSource).toBe("explicit");
|
||||
expect(result.workspaceDir).toBe(path.resolve("/tmp/openclaw-state", "workspace-research"));
|
||||
it("rejects an explicit agent when the supplied config has no roster", () => {
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
agentId: "research",
|
||||
config: {},
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_ROSTER_REQUIRED" }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ agentId: "research", sessionKey: undefined },
|
||||
{ agentId: undefined, sessionKey: "agent:research:subagent:test" },
|
||||
])("rejects an unconfigured workspace owner for $sessionKey", ({ agentId, sessionKey }) => {
|
||||
expect(() =>
|
||||
resolveRunWorkspaceDir({
|
||||
workspaceDir: undefined,
|
||||
agentId,
|
||||
sessionKey,
|
||||
config: { agents: { entries: { ops: { default: true } } } },
|
||||
}),
|
||||
).toThrow(expect.objectContaining({ code: "RUN_WORKSPACE_AGENT_NOT_CONFIGURED" }));
|
||||
});
|
||||
|
||||
it("throws for malformed agent session keys even when config has a default agent", () => {
|
||||
@@ -152,6 +175,7 @@ describe("resolveRunWorkspaceDir", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: fallbackWorkspace },
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
|
||||
+47
-12
@@ -8,12 +8,16 @@ import { logWarn } from "../logger.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import {
|
||||
classifySessionKeyShape,
|
||||
DEFAULT_AGENT_ID,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { hasAgentRosterProperty } from "./agent-scope-config.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "./agent-scope.js";
|
||||
import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
|
||||
|
||||
type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type";
|
||||
@@ -28,10 +32,32 @@ export type ResolveRunWorkspaceResult = {
|
||||
agentIdSource: AgentIdSource;
|
||||
};
|
||||
|
||||
const RUN_WORKSPACE_ROSTER_REQUIRED_ERROR_CODE = "RUN_WORKSPACE_ROSTER_REQUIRED";
|
||||
|
||||
class RunWorkspaceRosterRequiredError extends Error {
|
||||
readonly code = RUN_WORKSPACE_ROSTER_REQUIRED_ERROR_CODE;
|
||||
|
||||
constructor() {
|
||||
super("No agents configured; run workspace resolution requires an explicit roster.");
|
||||
this.name = "RunWorkspaceRosterRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
class RunWorkspaceAgentNotConfiguredError extends Error {
|
||||
readonly code = "RUN_WORKSPACE_AGENT_NOT_CONFIGURED";
|
||||
readonly agentId: string;
|
||||
|
||||
constructor(agentId: string) {
|
||||
super(`Agent ${agentId} is not present in the configured roster.`);
|
||||
this.name = "RunWorkspaceAgentNotConfiguredError";
|
||||
this.agentId = agentId;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRunAgentId(params: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
config?: OpenClawConfig;
|
||||
config: OpenClawConfig;
|
||||
}): {
|
||||
agentId: string;
|
||||
agentIdSource: AgentIdSource;
|
||||
@@ -50,10 +76,9 @@ function resolveRunAgentId(params: {
|
||||
return { agentId: explicit, agentIdSource: "explicit" };
|
||||
}
|
||||
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
if (shape === "missing" || shape === "legacy_or_alias") {
|
||||
return {
|
||||
agentId: defaultAgentId || DEFAULT_AGENT_ID,
|
||||
agentId: resolveDefaultAgentId(params.config),
|
||||
agentIdSource: "default",
|
||||
};
|
||||
}
|
||||
@@ -67,10 +92,7 @@ function resolveRunAgentId(params: {
|
||||
}
|
||||
|
||||
// Defensive fallback, should be unreachable for non-malformed shapes.
|
||||
return {
|
||||
agentId: defaultAgentId || DEFAULT_AGENT_ID,
|
||||
agentIdSource: "default",
|
||||
};
|
||||
throw new Error("Session key does not resolve to a configured agent.");
|
||||
}
|
||||
|
||||
/** Redacts a run/session identifier for logs and prompts. */
|
||||
@@ -86,13 +108,26 @@ export function resolveRunWorkspaceDir(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ResolveRunWorkspaceResult {
|
||||
const rawSessionKey = params.sessionKey?.trim() ?? "";
|
||||
if (classifySessionKeyShape(rawSessionKey) === "malformed_agent") {
|
||||
throw new Error("Malformed agent session key; refusing workspace resolution.");
|
||||
}
|
||||
// Workspace ownership is an isolation boundary. Raw/configless SDK inputs may
|
||||
// retain implicit-main routing compatibility, but must not invent an owner here.
|
||||
const config = params.config;
|
||||
if (!config || !hasAgentRosterProperty(config)) {
|
||||
throw new RunWorkspaceRosterRequiredError();
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const requested = params.workspaceDir;
|
||||
const { agentId, agentIdSource } = resolveRunAgentId({
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
config: params.config,
|
||||
config,
|
||||
});
|
||||
if (!resolveAgentConfig(config, agentId)) {
|
||||
throw new RunWorkspaceAgentNotConfiguredError(agentId);
|
||||
}
|
||||
if (typeof requested === "string") {
|
||||
const trimmed = requested.trim();
|
||||
if (trimmed) {
|
||||
@@ -102,7 +137,7 @@ export function resolveRunWorkspaceDir(params: {
|
||||
}
|
||||
const workspaceDir = resolveUserPath(sanitized, env);
|
||||
const canonicalWorkspaceDir = resolveUserPath(
|
||||
resolveAgentWorkspaceDir(params.config ?? {}, agentId, env),
|
||||
resolveAgentWorkspaceDir(config, agentId, env),
|
||||
env,
|
||||
);
|
||||
return {
|
||||
@@ -117,7 +152,7 @@ export function resolveRunWorkspaceDir(params: {
|
||||
|
||||
const fallbackReason: WorkspaceFallbackReason =
|
||||
requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type";
|
||||
const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId, env);
|
||||
const fallbackWorkspace = resolveAgentWorkspaceDir(config, agentId, env);
|
||||
const sanitizedFallback = sanitizeForPromptLiteral(fallbackWorkspace);
|
||||
if (sanitizedFallback !== fallbackWorkspace) {
|
||||
logWarn("Control/format characters stripped from fallback workspaceDir (OC-19 hardening).");
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelAliasIndex } from "../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { migratePersistedImplicitMainRoster } from "../config/legacy.roster.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { installDirectiveBehaviorE2EHooks } from "./reply.directive.directive-behavior.e2e-harness.js";
|
||||
import { runEmbeddedAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
|
||||
@@ -42,10 +43,11 @@ async function runDirectiveStatus(
|
||||
sessionKey: _ignoredSessionKey,
|
||||
sessionEntry: _ignoredSessionEntry,
|
||||
sessionStore: _ignoredSessionStore,
|
||||
cfg: overrideCfg,
|
||||
...restOverrides
|
||||
} = overrides;
|
||||
const result = await handleDirectiveOnly({
|
||||
cfg,
|
||||
cfg: migratePersistedImplicitMainRoster(overrideCfg ?? cfg).config as OpenClawConfig,
|
||||
directives: parseInlineDirectives(body),
|
||||
sessionEntry: effectiveSessionEntry,
|
||||
sessionStore: effectiveSessionStore,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveAgentConfig } from "../../agents/agent-scope-config.js";
|
||||
import { resolveContextTokensForModel } from "../../agents/context.js";
|
||||
import { resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
@@ -23,11 +24,7 @@ function resolveAgentHeartbeatModelRaw(params: {
|
||||
const defaultModel = normalizeOptionalString(params.cfg.agents?.defaults?.heartbeat?.model);
|
||||
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
|
||||
const agentModel = agentId
|
||||
? normalizeOptionalString(
|
||||
params.cfg.agents?.list?.find(
|
||||
(entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
|
||||
)?.heartbeat?.model,
|
||||
)
|
||||
? normalizeOptionalString(resolveAgentConfig(params.cfg, agentId)?.heartbeat?.model)
|
||||
: undefined;
|
||||
return agentModel ?? defaultModel;
|
||||
}
|
||||
@@ -75,11 +72,7 @@ function resolveAgentContextTokensForHint(params: {
|
||||
);
|
||||
const agentId = normalizeLowercaseStringOrEmpty(params.agentId);
|
||||
const agentContextTokens = agentId
|
||||
? normalizePositiveContextTokens(
|
||||
params.cfg.agents?.list?.find(
|
||||
(entry) => normalizeLowercaseStringOrEmpty(entry?.id) === agentId,
|
||||
)?.contextTokens,
|
||||
)
|
||||
? normalizePositiveContextTokens(resolveAgentConfig(params.cfg, agentId)?.contextTokens)
|
||||
: undefined;
|
||||
return agentContextTokens ?? defaultContextTokens;
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ function resolveSessionLogPath(
|
||||
sessionId?: string,
|
||||
sessionEntry?: SessionEntry,
|
||||
sessionKey?: string,
|
||||
opts?: { storePath?: string },
|
||||
opts?: { agentId?: string; storePath?: string },
|
||||
): string | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
@@ -424,7 +424,9 @@ function resolveSessionLogPath(
|
||||
if (parseSqliteSessionFileMarker(sessionFile)) {
|
||||
return sessionFile;
|
||||
}
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const agentId =
|
||||
opts?.agentId ??
|
||||
(sessionKey?.startsWith("agent:") ? resolveAgentIdFromSessionKey(sessionKey) : undefined);
|
||||
if (!sessionFile && agentId && opts?.storePath) {
|
||||
return formatSqliteSessionFileMarker({
|
||||
agentId,
|
||||
@@ -511,7 +513,7 @@ async function readSessionLogSnapshot(params: {
|
||||
sessionId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
opts?: { storePath?: string };
|
||||
opts?: { agentId?: string; storePath?: string };
|
||||
includeByteSize: boolean;
|
||||
includeUsage: boolean;
|
||||
}): Promise<SessionLogSnapshot> {
|
||||
@@ -652,6 +654,7 @@ type TranscriptTokenEstimate = {
|
||||
};
|
||||
|
||||
async function estimatePromptTokensFromSessionTranscript(params: {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
@@ -666,7 +669,7 @@ async function estimatePromptTokensFromSessionTranscript(params: {
|
||||
sessionId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
opts: { storePath: params.storePath },
|
||||
opts: { agentId: params.agentId, storePath: params.storePath },
|
||||
includeByteSize: true,
|
||||
includeUsage: true,
|
||||
});
|
||||
@@ -840,6 +843,7 @@ export async function runPreflightCompactionIfNeeded(params: {
|
||||
typeof freshPersistedTokens === "number" && !freshNeedsOutputRead
|
||||
? undefined
|
||||
: await estimatePromptTokensFromSessionTranscript({
|
||||
agentId: params.followupRun.run.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
|
||||
@@ -851,7 +855,7 @@ export async function runPreflightCompactionIfNeeded(params: {
|
||||
sessionId: entry.sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
|
||||
opts: { storePath: params.storePath },
|
||||
opts: { agentId: params.followupRun.run.agentId, storePath: params.storePath },
|
||||
includeByteSize: true,
|
||||
includeUsage: false,
|
||||
})
|
||||
@@ -954,7 +958,7 @@ export async function runPreflightCompactionIfNeeded(params: {
|
||||
entry.sessionId,
|
||||
entry,
|
||||
params.sessionKey ?? params.followupRun.run.sessionKey,
|
||||
{ storePath: params.storePath },
|
||||
{ agentId: params.followupRun.run.agentId, storePath: params.storePath },
|
||||
);
|
||||
if (!sessionFile) {
|
||||
await notifyTerminalCompaction("skipped");
|
||||
@@ -1191,7 +1195,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
sessionId: params.followupRun.run.sessionId,
|
||||
sessionEntry: entry,
|
||||
sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey,
|
||||
opts: { storePath: params.storePath },
|
||||
opts: { agentId: params.followupRun.run.agentId, storePath: params.storePath },
|
||||
includeByteSize: shouldCheckTranscriptSizeForForcedFlush,
|
||||
includeUsage: shouldReadTranscript,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { clearAllCliSessions } from "../../agents/cli-session.js";
|
||||
// Handles session reset requests produced during agent runner execution.
|
||||
import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../config/sessions.js";
|
||||
import { persistSessionResetLifecycle } from "../../config/sessions/session-accessor.js";
|
||||
import {
|
||||
formatSqliteSessionFileMarker,
|
||||
@@ -107,7 +106,7 @@ export async function resetReplyRunSession(params: {
|
||||
clearAllCliSessions(nextEntry);
|
||||
nextEntry.agentHarnessId = undefined;
|
||||
transitionMainSessionRecovery(nextEntry, { kind: "clear" });
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const agentId = params.followupRun.run.agentId;
|
||||
const nextSessionFile =
|
||||
(sqliteSessionFileMarkerMatchesTarget(prevEntry.sessionFile, {
|
||||
agentId,
|
||||
|
||||
@@ -541,7 +541,7 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
it("keeps an unarmed preflight drain visible instead of dropping the reply", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-preflight-drain-"));
|
||||
const storePath = path.join(tmp, "sessions.json");
|
||||
const sessionKey = "main";
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
@@ -579,6 +579,7 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
typingMode: "instant",
|
||||
});
|
||||
|
||||
expect(compactState.compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1);
|
||||
expectReplyText(result, "⚠️ Gateway is restarting. Please wait a few seconds and try again.");
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,12 @@ function firstBeforeResetCall() {
|
||||
}
|
||||
|
||||
describe("emitResetCommandHooks", () => {
|
||||
async function runBeforeResetContext(sessionKey?: string) {
|
||||
async function runBeforeResetContext(
|
||||
sessionKey?: string,
|
||||
cfg: HandleCommandsParams["cfg"] = {
|
||||
agents: { entries: { main: { default: true } } },
|
||||
},
|
||||
) {
|
||||
const command = {
|
||||
surface: "discord",
|
||||
senderId: "rai",
|
||||
@@ -51,7 +56,7 @@ describe("emitResetCommandHooks", () => {
|
||||
await emitResetCommandHooks({
|
||||
action: "new",
|
||||
ctx: {} as HandleCommandsParams["ctx"],
|
||||
cfg: {} as HandleCommandsParams["cfg"],
|
||||
cfg,
|
||||
command,
|
||||
sessionKey,
|
||||
previousSessionEntry: {
|
||||
@@ -87,9 +92,11 @@ describe("emitResetCommandHooks", () => {
|
||||
expect(ctx?.workspaceDir).toBe("/tmp/openclaw-workspace");
|
||||
});
|
||||
|
||||
it("falls back to main when the reset hook has no session key", async () => {
|
||||
const ctx = await runBeforeResetContext(undefined);
|
||||
expect(ctx?.agentId).toBe("main");
|
||||
it("uses the configured default when the reset hook has no session key", async () => {
|
||||
const ctx = await runBeforeResetContext(undefined, {
|
||||
agents: { entries: { ops: { default: true } } },
|
||||
});
|
||||
expect(ctx?.agentId).toBe("ops");
|
||||
expect(ctx?.sessionKey).toBeUndefined();
|
||||
expect(ctx?.sessionId).toBe("prev-session");
|
||||
expect(ctx?.workspaceDir).toBe("/tmp/openclaw-workspace");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Tests /learn prompt rewriting, defaults, standards, and availability gating.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migratePersistedImplicitMainRoster } from "../../config/legacy.roster.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { DEFAULT_LEARN_REQUEST } from "../../skills/workshop/learn-prompt.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
@@ -30,8 +31,9 @@ function buildLearnParams(
|
||||
commandBodyNormalized: string,
|
||||
cfg: OpenClawConfig = {},
|
||||
): HandleCommandsParams {
|
||||
const loadedConfig = migratePersistedImplicitMainRoster(cfg).config as OpenClawConfig;
|
||||
return {
|
||||
cfg: { ...cfg, models: cfg.models ?? DEFAULT_TEST_MODELS },
|
||||
cfg: { ...loadedConfig, models: loadedConfig.models ?? DEFAULT_TEST_MODELS },
|
||||
ctx: {
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user