feat: virtual agents v3 (Stages 1-3) + real fixes for chat/adapter/CLI thread format #67
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user