mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14810a5090 |
+49
-34
@@ -36,6 +36,7 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
|
||||
import './providers/index.js';
|
||||
import {
|
||||
getProviderContainerConfig,
|
||||
providerProvidesAgentSurfaces,
|
||||
type ProviderContainerContribution,
|
||||
type VolumeMount,
|
||||
} from './providers/provider-container-registry.js';
|
||||
@@ -127,12 +128,19 @@ async function spawnContainer(session: Session): Promise<void> {
|
||||
// and buildContainerArgs so we don't re-read.
|
||||
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before. Runs before the provider
|
||||
// contribution so a surfaces-providing provider finds the group dir ready.
|
||||
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
|
||||
initGroupFilesystem(agentGroup, { provider: providerName });
|
||||
|
||||
// Resolve the effective provider + any host-side contribution it declares
|
||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
|
||||
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
|
||||
|
||||
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
|
||||
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
|
||||
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
|
||||
// OneCLI agent identifier is always the agent group id — stable across
|
||||
// sessions and reversible via getAgentGroup() for approval routing.
|
||||
@@ -234,32 +242,37 @@ function resolveProviderContribution(
|
||||
? fn({
|
||||
sessionDir: sessionDir(agentGroup.id, session.id),
|
||||
agentGroupId: agentGroup.id,
|
||||
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
|
||||
selectedSkills: selectedSkillNames(containerConfig),
|
||||
hostEnv: process.env,
|
||||
})
|
||||
: {};
|
||||
return { provider, contribution };
|
||||
}
|
||||
|
||||
function buildMounts(
|
||||
export function buildMounts(
|
||||
agentGroup: AgentGroup,
|
||||
session: Session,
|
||||
containerConfig: import('./container-config.js').ContainerConfig,
|
||||
provider: string,
|
||||
providerContribution: ProviderContainerContribution,
|
||||
): VolumeMount[] {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Per-group filesystem state lives forever after first creation. Init is
|
||||
// idempotent: it only writes paths that don't already exist, so this call
|
||||
// is a no-op for groups that have spawned before.
|
||||
initGroupFilesystem(agentGroup);
|
||||
// Default agent surfaces (composed project doc, skill links, provider state
|
||||
// dir) apply unless the provider's registration declares it provides its
|
||||
// own — a capability, never a provider name. See provider-container-registry.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
|
||||
|
||||
// Sync skill symlinks based on container.json selection before mounting.
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
|
||||
syncSkillSymlinks(claudeDir, containerConfig);
|
||||
if (defaultSurfaces) {
|
||||
// Sync skill symlinks based on container.json selection before mounting.
|
||||
syncSkillSymlinks(claudeDir, containerConfig);
|
||||
|
||||
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
|
||||
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
|
||||
composeGroupClaudeMd(agentGroup);
|
||||
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
|
||||
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
|
||||
composeGroupClaudeMd(agentGroup);
|
||||
}
|
||||
|
||||
const mounts: VolumeMount[] = [];
|
||||
const sessDir = sessionDir(agentGroup.id, session.id);
|
||||
@@ -286,11 +299,11 @@ function buildMounts(
|
||||
// already RO-mounted, so writes through it fail regardless — no need for
|
||||
// a nested mount there.
|
||||
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
if (fs.existsSync(composedClaudeMd)) {
|
||||
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
|
||||
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
|
||||
}
|
||||
const fragmentsDir = path.join(groupDir, '.claude-fragments');
|
||||
if (fs.existsSync(fragmentsDir)) {
|
||||
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
|
||||
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
|
||||
}
|
||||
|
||||
@@ -303,13 +316,15 @@ function buildMounts(
|
||||
// Shared CLAUDE.md — read-only, imported by the composed entry point via
|
||||
// the `.claude-shared.md` symlink inside the group dir.
|
||||
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
|
||||
if (fs.existsSync(sharedClaudeMd)) {
|
||||
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
|
||||
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
|
||||
}
|
||||
|
||||
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
|
||||
// skill symlinks)
|
||||
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
|
||||
if (defaultSurfaces) {
|
||||
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
|
||||
}
|
||||
|
||||
// Shared agent-runner source — read-only, same code for all groups.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
@@ -346,25 +361,7 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Determine desired skill set
|
||||
const projectRoot = process.cwd();
|
||||
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
|
||||
let desired: string[];
|
||||
if (containerConfig.skills === 'all') {
|
||||
// Recompute from shared dir — newly-added upstream skills appear automatically
|
||||
desired = fs.existsSync(sharedSkillsDir)
|
||||
? fs.readdirSync(sharedSkillsDir).filter((e) => {
|
||||
try {
|
||||
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
} else {
|
||||
desired = containerConfig.skills;
|
||||
}
|
||||
|
||||
const desired = selectedSkillNames(containerConfig);
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Remove symlinks not in the desired set
|
||||
@@ -397,6 +394,24 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the group's skill selection to concrete names — `'all'` recomputes
|
||||
* from `container/skills/` so newly-added upstream skills appear automatically.
|
||||
*/
|
||||
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
|
||||
if (containerConfig.skills !== 'all') return containerConfig.skills;
|
||||
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
|
||||
return fs.existsSync(sharedSkillsDir)
|
||||
? fs.readdirSync(sharedSkillsDir).filter((e) => {
|
||||
try {
|
||||
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
async function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
|
||||
+32
-20
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { log } from './log.js';
|
||||
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
const DEFAULT_SETTINGS_JSON =
|
||||
@@ -46,9 +47,18 @@ const DEFAULT_SETTINGS_JSON =
|
||||
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
|
||||
* per-group instructions (if provided) seed `CLAUDE.local.md`.
|
||||
*/
|
||||
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
|
||||
export function initGroupFilesystem(
|
||||
group: AgentGroup,
|
||||
opts?: { instructions?: string; provider?: string | null },
|
||||
): void {
|
||||
const initialized: string[] = [];
|
||||
|
||||
// Default agent surfaces apply unless the group's provider declares (at
|
||||
// registration) that it provides its own. Callers that don't know the
|
||||
// provider omit it — unregistered/unknown names report no capabilities,
|
||||
// so the default surfaces are written, exactly as before this seam.
|
||||
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
|
||||
|
||||
// 1. groups/<folder>/ — group memory + working dir
|
||||
const groupDir = path.resolve(GROUPS_DIR, group.folder);
|
||||
if (!fs.existsSync(groupDir)) {
|
||||
@@ -59,7 +69,7 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
|
||||
// Claude Code. Seeded with caller-provided instructions on first creation.
|
||||
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
|
||||
if (!fs.existsSync(claudeLocalFile)) {
|
||||
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
|
||||
const body = opts?.instructions ? opts.instructions + '\n' : '';
|
||||
fs.writeFileSync(claudeLocalFile, body);
|
||||
initialized.push('CLAUDE.local.md');
|
||||
@@ -71,26 +81,28 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
initialized.push('container_configs');
|
||||
|
||||
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
initialized.push('.claude-shared');
|
||||
}
|
||||
if (defaultSurfaces) {
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
|
||||
if (!fs.existsSync(claudeDir)) {
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
initialized.push('.claude-shared');
|
||||
}
|
||||
|
||||
const settingsFile = path.join(claudeDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
|
||||
initialized.push('settings.json');
|
||||
} else {
|
||||
ensurePreCompactHook(settingsFile, initialized);
|
||||
}
|
||||
const settingsFile = path.join(claudeDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
|
||||
initialized.push('settings.json');
|
||||
} else {
|
||||
ensurePreCompactHook(settingsFile, initialized);
|
||||
}
|
||||
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
// time by container-runner.ts based on container.json skills selection.
|
||||
const skillsDst = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDst)) {
|
||||
fs.mkdirSync(skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
// Skills directory — created empty here; symlinks are synced at spawn
|
||||
// time by container-runner.ts based on container.json skills selection.
|
||||
const skillsDst = path.join(claudeDir, 'skills');
|
||||
if (!fs.existsSync(skillsDst)) {
|
||||
fs.mkdirSync(skillsDst, { recursive: true });
|
||||
initialized.push('skills/');
|
||||
}
|
||||
}
|
||||
|
||||
if (initialized.length > 0) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
|
||||
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
|
||||
const DATA_DIR = path.join(TEST_ROOT, 'data');
|
||||
|
||||
vi.mock('./config.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./config.js')>()),
|
||||
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
|
||||
}));
|
||||
|
||||
vi.mock('./log.js', () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { buildMounts } from './container-runner.js';
|
||||
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
|
||||
import { ensureContainerConfig } from './db/container-configs.js';
|
||||
import { initGroupFilesystem } from './group-init.js';
|
||||
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
|
||||
import type { ContainerConfig } from './container-config.js';
|
||||
import type { AgentGroup, Session } from './types.js';
|
||||
|
||||
// A provider that declares (at registration) that it owns its agent surfaces.
|
||||
// Registered once — the registry is module-global and rejects duplicates.
|
||||
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
|
||||
|
||||
function group(id: string, folder: string): AgentGroup {
|
||||
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
|
||||
}
|
||||
|
||||
function session(id: string, agentGroupId: string): Session {
|
||||
return { id, agent_group_id: agentGroupId } as Session;
|
||||
}
|
||||
|
||||
function containerConfig(): ContainerConfig {
|
||||
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
||||
runMigrations(initTestDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('initGroupFilesystem agent surfaces', () => {
|
||||
it('writes the default surfaces when no provider is given (today’s behavior)', () => {
|
||||
const ag = group('ag-default', 'default-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { instructions: 'hello' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, ag.folder);
|
||||
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
|
||||
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the default surfaces for a provider that provides its own', () => {
|
||||
const ag = group('ag-surfy', 'surfy-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, ag.folder);
|
||||
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
|
||||
expect(fs.existsSync(groupDir)).toBe(true);
|
||||
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an unregistered provider name as default surfaces', () => {
|
||||
const ag = group('ag-unknown', 'unknown-group');
|
||||
createAgentGroup(ag);
|
||||
|
||||
initGroupFilesystem(ag, { provider: 'not-registered' });
|
||||
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMounts agent surfaces', () => {
|
||||
it('mounts the default surfaces for an unregistered provider (today’s behavior)', () => {
|
||||
const ag = group('ag-mounts-default', 'mounts-default');
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
initGroupFilesystem(ag, {});
|
||||
|
||||
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
|
||||
|
||||
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
|
||||
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
|
||||
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
|
||||
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
|
||||
// Composer ran: the generated project doc exists on disk.
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
|
||||
const ag = group('ag-mounts-surfy', 'mounts-surfy');
|
||||
createAgentGroup(ag);
|
||||
ensureContainerConfig(ag.id);
|
||||
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
|
||||
|
||||
const contributed = {
|
||||
mounts: [
|
||||
{
|
||||
hostPath: path.join(GROUPS_DIR, ag.folder),
|
||||
containerPath: '/workspace/agent/OWN-DOC.md',
|
||||
readonly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
|
||||
|
||||
const containerPaths = mounts.map((m) => m.containerPath);
|
||||
expect(containerPaths).not.toContain('/home/node/.claude');
|
||||
expect(containerPaths).not.toContain('/app/CLAUDE.md');
|
||||
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
|
||||
// Composer did NOT run for this group.
|
||||
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
|
||||
// Core mounts and the provider's own contribution are intact.
|
||||
expect(containerPaths).toContain('/workspace');
|
||||
expect(containerPaths).toContain('/workspace/agent');
|
||||
expect(containerPaths).toContain('/app/src');
|
||||
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,19 @@ export interface ProviderContainerContext {
|
||||
sessionDir: string;
|
||||
/** Agent group ID, for any per-group logic. */
|
||||
agentGroupId: string;
|
||||
/**
|
||||
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
|
||||
* `/workspace/agent`). Exists by the time the config fn runs — group
|
||||
* filesystem init happens first. Surfaces-providing providers compose
|
||||
* their project doc and skill links here.
|
||||
*/
|
||||
groupDir: string;
|
||||
/**
|
||||
* Skill names selected by the group's container config, with `'all'`
|
||||
* already resolved against `container/skills/`. Surfaces-providing
|
||||
* providers use this to sync their own skill-discovery links.
|
||||
*/
|
||||
selectedSkills: string[];
|
||||
/** `process.env` at spawn time — pull passthrough values from here. */
|
||||
hostEnv: NodeJS.ProcessEnv;
|
||||
}
|
||||
@@ -38,19 +51,56 @@ export interface ProviderContainerContribution {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static capabilities a provider declares at registration time — knowable
|
||||
* without a spawn context, so any host path (group init, spawn, creation
|
||||
* flows) can consult them by name.
|
||||
*/
|
||||
export interface ProviderHostCapabilities {
|
||||
/**
|
||||
* Optional. When true, this provider owns its agent-facing surfaces — the
|
||||
* composed project doc, skill-discovery links, and provider state dir —
|
||||
* and the host must NOT compose or mount the default ones (composed
|
||||
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
|
||||
* `CLAUDE.local.md` seeding). The provider's config fn does its own
|
||||
* composing and returns its own mounts. Default off — providers that omit
|
||||
* this get the default surfaces, which is today's behavior.
|
||||
*/
|
||||
readonly providesAgentSurfaces?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
|
||||
|
||||
const registry = new Map<string, ProviderContainerConfigFn>();
|
||||
interface RegistryEntry {
|
||||
fn: ProviderContainerConfigFn;
|
||||
capabilities: ProviderHostCapabilities;
|
||||
}
|
||||
|
||||
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
|
||||
const registry = new Map<string, RegistryEntry>();
|
||||
|
||||
export function registerProviderContainerConfig(
|
||||
name: string,
|
||||
fn: ProviderContainerConfigFn,
|
||||
capabilities: ProviderHostCapabilities = {},
|
||||
): void {
|
||||
if (registry.has(name)) {
|
||||
throw new Error(`Provider container config already registered: ${name}`);
|
||||
}
|
||||
registry.set(name, fn);
|
||||
registry.set(name, { fn, capabilities });
|
||||
}
|
||||
|
||||
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
|
||||
return registry.get(name);
|
||||
return registry.get(name)?.fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability lookup by provider name. Unregistered providers (including the
|
||||
* baked-in default) report no capabilities — the host applies its default
|
||||
* surfaces, exactly as before this seam existed.
|
||||
*/
|
||||
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
|
||||
if (!name) return false;
|
||||
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
|
||||
}
|
||||
|
||||
export function listProviderContainerConfigNames(): string[] {
|
||||
|
||||
Reference in New Issue
Block a user