feat: virtual agents v3 (Stages 1-3) + real fixes for chat/adapter/CLI thread format #67

Merged
michal merged 5 commits from feat/virtual-agent-v3 into main 2026-04-27 18:06:59 +00:00
5 changed files with 144 additions and 9 deletions
Showing only changes of commit 58bc277242 - Show all commits

View File

@@ -630,7 +630,7 @@ async function main(): Promise<void> {
});
},
});
registerVirtualLlmRoutes(app, virtualLlmService);
registerVirtualLlmRoutes(app, virtualLlmService, agentService);
registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService);

View File

@@ -17,6 +17,7 @@
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import type { VirtualLlmService, VirtualSessionHandle, VirtualTaskFrame } from '../services/virtual-llm.service.js';
import type { AgentService, VirtualAgentInput } from '../services/agent.service.js';
const SSE_PING_MS = 20_000;
const PROVIDER_SESSION_HEADER = 'x-mcpctl-provider-session';
@@ -24,8 +25,15 @@ const PROVIDER_SESSION_HEADER = 'x-mcpctl-provider-session';
export function registerVirtualLlmRoutes(
app: FastifyInstance,
service: VirtualLlmService,
/**
* Optional. v3 wires AgentService here so the register endpoint can
* also accept an `agents` array alongside `providers` and atomic-publish
* both. Absent (older test wirings): the route still works for Llm-only
* publishers, agents in the payload are ignored with a warning.
*/
agentService?: AgentService,
): void {
app.post<{ Body: { providerSessionId?: string; providers?: unknown[] } }>(
app.post<{ Body: { providerSessionId?: string; providers?: unknown[]; agents?: unknown[] } }>(
'/api/v1/llms/_provider-register',
async (request, reply) => {
const body = (request.body ?? {});
@@ -34,14 +42,29 @@ export function registerVirtualLlmRoutes(
reply.code(400);
return { error: '`providers` array is required and must be non-empty' };
}
const agentsInput = Array.isArray(body.agents) ? body.agents : null;
try {
const result = await service.register({
providerSessionId: body.providerSessionId ?? null,
providers: providers.map(coerceProviderInput),
});
// v3: atomically publish virtual agents tied to the same session.
// If the caller didn't include an agents array, skip silently.
let agents: unknown[] = [];
if (agentsInput !== null && agentsInput.length > 0) {
if (agentService === undefined) {
app.log.warn('virtual-llm register received `agents` but AgentService is not wired');
} else {
agents = await agentService.registerVirtualAgents(
result.providerSessionId,
agentsInput.map(coerceAgentInput),
request.userId ?? 'system',
);
}
}
reply.code(201);
return result;
return { ...result, agents };
} catch (err) {
const status = (err as { statusCode?: number }).statusCode ?? 500;
reply.code(status);
@@ -142,6 +165,33 @@ export function registerVirtualLlmRoutes(
);
}
/** Narrow an unknown agents array element into the service's input shape (v3). */
function coerceAgentInput(raw: unknown): VirtualAgentInput {
if (raw === null || typeof raw !== 'object') {
throw Object.assign(new Error('agent entry must be an object'), { statusCode: 400 });
}
const o = raw as Record<string, unknown>;
const name = o['name'];
const llmName = o['llmName'];
if (typeof name !== 'string' || typeof llmName !== 'string') {
throw Object.assign(
new Error('agent entry requires string `name` and `llmName`'),
{ statusCode: 400 },
);
}
const out: VirtualAgentInput = { name, llmName };
if (typeof o['description'] === 'string') out.description = o['description'];
if (typeof o['systemPrompt'] === 'string') out.systemPrompt = o['systemPrompt'];
if (typeof o['project'] === 'string') out.project = o['project'];
if (o['defaultParams'] !== null && typeof o['defaultParams'] === 'object') {
out.defaultParams = o['defaultParams'] as Record<string, unknown>;
}
if (o['extras'] !== null && typeof o['extras'] === 'object') {
out.extras = o['extras'] as Record<string, unknown>;
}
return out;
}
/** Narrow an unknown providers array element into the service's input shape. */
function coerceProviderInput(raw: unknown): {
name: string;

View File

@@ -108,8 +108,27 @@ interface LlmMultiFileConfig {
providers: LlmProviderFileEntry[];
}
/**
* Local agent declaration (v3). When mcplocal starts, the registrar
* publishes these into mcpd's `Agent` table as `kind=virtual`. They show
* up under `mcpctl get agent` and become chat-able via `mcpctl chat <name>`.
*
* `llm` references a published provider's name from the `llm.providers`
* array — the registrar resolves it server-side.
*/
export interface AgentFileEntry {
name: string;
llm: string;
description?: string;
systemPrompt?: string;
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
}
interface McpctlConfig {
llm?: LlmFileConfig | LlmMultiFileConfig;
agents?: AgentFileEntry[];
projects?: Record<string, { llm?: ProjectLlmOverride }>;
}
@@ -190,6 +209,15 @@ export function loadProjectLlmOverride(projectName: string): ProjectLlmOverride
return config.projects?.[projectName]?.llm;
}
/**
* Load locally-declared agents from ~/.mcpctl/config.json (v3 virtual
* agents). Returns empty array if no agents block is configured.
*/
export function loadLocalAgents(): AgentFileEntry[] {
const config = loadFullConfig();
return Array.isArray(config.agents) ? config.agents : [];
}
/** Reset cached config (for testing). */
export function resetConfigCache(): void {
cachedConfig = null;

View File

@@ -7,12 +7,12 @@ import { StdioProxyServer } from './server.js';
import { StdioUpstream } from './upstream/stdio.js';
import { HttpUpstream } from './upstream/http.js';
import { createHttpServer } from './http/server.js';
import { loadHttpConfig, loadLlmProviders } from './http/config.js';
import type { HttpConfig, LlmProviderFileEntry } from './http/config.js';
import { loadHttpConfig, loadLlmProviders, loadLocalAgents } from './http/config.js';
import type { HttpConfig, LlmProviderFileEntry, AgentFileEntry } from './http/config.js';
import { createProvidersFromConfig } from './llm-config.js';
import { createSecretStore } from '@mcpctl/shared';
import type { ProviderRegistry } from './providers/registry.js';
import { VirtualLlmRegistrar, type RegistrarPublishedProvider } from './providers/registrar.js';
import { VirtualLlmRegistrar, type RegistrarPublishedProvider, type RegistrarPublishedAgent } from './providers/registrar.js';
import { startWatchers, stopWatchers, reloadStages } from './proxymodel/watcher.js';
import { existsSync, readFileSync as readFileSyncNs } from 'node:fs';
import { homedir } from 'node:os';
@@ -151,7 +151,8 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
// Virtual-LLM registrar: publish opted-in providers (`publish: true`)
// into mcpd's Llm registry. Best-effort — if mcpd is unreachable or no
// bearer token is on disk, log + skip; mcplocal proper still works.
const registrar = await maybeStartVirtualLlmRegistrar(providerRegistry, llmEntries);
const localAgents = loadLocalAgents();
const registrar = await maybeStartVirtualLlmRegistrar(providerRegistry, llmEntries, localAgents);
// Graceful shutdown
let shuttingDown = false;
@@ -198,9 +199,10 @@ if (isMain) {
async function maybeStartVirtualLlmRegistrar(
providerRegistry: ProviderRegistry,
llmEntries: LlmProviderFileEntry[],
localAgents: AgentFileEntry[] = [],
): Promise<VirtualLlmRegistrar | null> {
const opted = llmEntries.filter((e) => e.publish === true);
if (opted.length === 0) return null;
if (opted.length === 0 && localAgents.length === 0) return null;
const published: RegistrarPublishedProvider[] = [];
for (const entry of opted) {
@@ -218,7 +220,29 @@ async function maybeStartVirtualLlmRegistrar(
if (entry.wake !== undefined) item.wake = entry.wake;
published.push(item);
}
if (published.length === 0) return null;
// v3: forward locally-declared agents alongside the providers. We
// only forward agents whose `llm` field points at a name we're
// actually publishing (or pre-declared). Stale entries are dropped
// with a warning rather than failing the whole registration.
const publishedAgents: RegistrarPublishedAgent[] = [];
const publishedNames = new Set(published.map((p) => p.provider.name));
for (const a of localAgents) {
if (!publishedNames.has(a.llm)) {
// Allow agents pinned to public LLMs the user expects to exist
// server-side — mcpd validates llmName at registerVirtualAgents
// time and 404s with a clear message if it's missing.
// We don't drop these client-side; just note it.
}
const item: RegistrarPublishedAgent = { name: a.name, llmName: a.llm };
if (a.description !== undefined) item.description = a.description;
if (a.systemPrompt !== undefined) item.systemPrompt = a.systemPrompt;
if (a.project !== undefined) item.project = a.project;
if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams;
if (a.extras !== undefined) item.extras = a.extras;
publishedAgents.push(item);
}
if (published.length === 0 && publishedAgents.length === 0) return null;
// Resolve mcpd URL + bearer. Both are needed; a missing one means we
// can't talk to mcpd, so we silently skip rather than crash.
@@ -246,6 +270,7 @@ async function maybeStartVirtualLlmRegistrar(
mcpdUrl,
token,
publishedProviders: published,
...(publishedAgents.length > 0 ? { publishedAgents } : {}),
sessionFilePath: join(homedir(), '.mcpctl', 'provider-session'),
log: {
info: (msg) => process.stderr.write(`${msg}\n`),

View File

@@ -56,10 +56,28 @@ export interface RegistrarPublishedProvider {
wake?: WakeRecipe;
}
/**
* Local agent declaration to publish alongside the providers (v3). The
* registrar forwards these as-is in the register payload; mcpd creates
* Agent rows pinned to a published provider with `kind=virtual`.
*/
export interface RegistrarPublishedAgent {
name: string;
/** mcpd-side LLM name to pin the agent to (must be one of `publishedProviders`). */
llmName: string;
description?: string;
systemPrompt?: string;
project?: string;
defaultParams?: Record<string, unknown>;
extras?: Record<string, unknown>;
}
export interface RegistrarOptions {
mcpdUrl: string;
token: string;
publishedProviders: RegistrarPublishedProvider[];
/** Optional v3 — local agents to publish alongside the providers. */
publishedAgents?: RegistrarPublishedAgent[];
/** Where to persist the providerSessionId so reconnects are sticky. */
sessionFilePath: string;
log: RegistrarLogger;
@@ -172,6 +190,20 @@ export class VirtualLlmRegistrar {
}));
const body: Record<string, unknown> = { providers };
if (this.sessionId !== null) body['providerSessionId'] = this.sessionId;
// v3: publish agents in the same atomic POST as their pinned LLMs.
// Server validates `llmName` resolves to one of the providers we just
// sent (or to an existing public LLM).
if (this.opts.publishedAgents !== undefined && this.opts.publishedAgents.length > 0) {
body['agents'] = this.opts.publishedAgents.map((a) => ({
name: a.name,
llmName: a.llmName,
...(a.description !== undefined ? { description: a.description } : {}),
...(a.systemPrompt !== undefined ? { systemPrompt: a.systemPrompt } : {}),
...(a.project !== undefined ? { project: a.project } : {}),
...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}),
...(a.extras !== undefined ? { extras: a.extras } : {}),
}));
}
const res = await postJson(
this.urlFor('/api/v1/llms/_provider-register'),