Skip to content

Commit d8628d3

Browse files
bloveclaude
andcommitted
feat(cockpit): per-topic framework adapters in the AG-UI deployment generator
Generalizes scripts/generate-ag-ui-deployment-config.ts away from its hardcoded LangGraph assumptions so a registry capability can declare which AG-UI backend framework serves it: - capability-registry: new optional 'framework' discriminator ('langgraph' | 'microsoft-agent-framework', default 'langgraph') and a 'runtimes' product for the one-capability-many-runtimes axis (cockpit/runtimes/<runtime>/). No entries added — pure type extension. - generator: FRAMEWORK_ADAPTERS maps framework -> {bridge import, per-topic module import, FastAPI mount block}. LangGraph keeps the exact emitted bytes; the MAF adapter mounts a module-level 'agent' object via add_agent_framework_fastapi_endpoint (no wrapper class). Bridge imports are emitted only for frameworks in use, langgraph first. - generate-shared-deployment-config + serve-example treat 'runtimes' like 'ag-ui' (Railway-deployed uvicorn AG-UI backends, no langgraph.json). - spec: adapter-branching tests plus a committed-artifact byte-equality test mirroring the deploy-ag-ui drift check. With only this change applied the generated deployments/ag-ui-dev artifacts are byte-identical to the committed ones (verified via regeneration + git diff). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 63377b6 commit d8628d3

5 files changed

Lines changed: 157 additions & 21 deletions

File tree

apps/cockpit/scripts/capability-registry.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@
22
* Single source of truth for all cockpit capability examples.
33
* Used by serve, build, test, and deploy scripts.
44
*/
5+
/**
6+
* Backend framework of an AG-UI-served capability (products 'ag-ui' and
7+
* 'runtimes'). Selects the framework adapter in
8+
* scripts/generate-ag-ui-deployment-config.ts: the bridge import, the
9+
* per-topic module contract (`src/graph.py` exposing `graph` for LangGraph
10+
* vs `src/agent.py` exposing `agent` for Microsoft Agent Framework), and
11+
* the FastAPI mount call. Omitted means 'langgraph'.
12+
*/
13+
export type CapabilityFramework = 'langgraph' | 'microsoft-agent-framework';
14+
515
export interface Capability {
616
id: string;
7-
product: 'langgraph' | 'deep-agents' | 'render' | 'chat' | 'ag-ui';
17+
/**
18+
* 'runtimes' is the one-capability-many-runtimes axis
19+
* (cockpit/runtimes/<runtime>/): non-LangGraph AG-UI backends measured
20+
* against the same neutral Agent contract. Like 'ag-ui' caps, they are
21+
* served by the aggregated deployments/ag-ui-dev FastAPI app.
22+
*/
23+
product: 'langgraph' | 'deep-agents' | 'render' | 'chat' | 'ag-ui' | 'runtimes';
824
topic: string;
925
angularProject: string;
1026
port: number;
@@ -13,6 +29,8 @@ export interface Capability {
1329
pythonDir?: string;
1430
/** Optional — see pythonDir. */
1531
graphName?: string;
32+
/** AG-UI backend framework; defaults to 'langgraph' when omitted. */
33+
framework?: CapabilityFramework;
1634
}
1735

1836
export const capabilities: readonly Capability[] = [

apps/cockpit/scripts/serve-example.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const COCKPIT_RUNTIME_ENV = { NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL: '' }
99
export function backendCommand(cap: Capability): string | null {
1010
if (!cap.pythonDir) return null;
1111
const enter = `cd ${cap.pythonDir} && source $HOME/.local/bin/env 2>/dev/null; uv sync`;
12-
if (cap.product === 'ag-ui') {
12+
if (cap.product === 'ag-ui' || cap.product === 'runtimes') {
1313
return `${enter} && uv run uvicorn src.server:app --port ${cap.pythonPort}`;
1414
}
1515
return `${enter} && uv run langgraph dev --port ${cap.pythonPort} --no-browser`;

scripts/generate-ag-ui-deployment-config.spec.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
22
import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from 'fs';
33
import { tmpdir } from 'os';
44
import { join, resolve } from 'path';
5-
import { generateAgUiDeployment } from './generate-ag-ui-deployment-config';
5+
import { buildServerPy, generateAgUiDeployment, type AgUiTopic } from './generate-ag-ui-deployment-config';
66

77
const REPO_ROOT = resolve(__dirname, '..');
88

@@ -72,6 +72,19 @@ describe('generateAgUiDeployment', () => {
7272
expect(reqs).not.toMatch(/^-e \./m);
7373
});
7474

75+
it('matches the committed deployments/ag-ui-dev artifacts byte-for-byte (drift check)', () => {
76+
// The deploy-ag-ui workflow regenerates and fails on `git diff` drift.
77+
// This is the same guarantee, runnable locally without touching the
78+
// committed artifacts.
79+
generateAgUiDeployment({ repoRoot: REPO_ROOT, outDir });
80+
const committedDir = join(REPO_ROOT, 'deployments/ag-ui-dev');
81+
for (const file of ['server.py', 'requirements.txt']) {
82+
expect(readFileSync(join(outDir, file), 'utf8')).toBe(
83+
readFileSync(join(committedDir, file), 'utf8'),
84+
);
85+
}
86+
});
87+
7588
it('produces byte-identical output across runs (idempotent)', () => {
7689
generateAgUiDeployment({ repoRoot: REPO_ROOT, outDir });
7790
const firstServer = readFileSync(join(outDir, 'server.py'), 'utf8');
@@ -81,3 +94,52 @@ describe('generateAgUiDeployment', () => {
8194
expect(readFileSync(join(outDir, 'requirements.txt'), 'utf8')).toBe(firstReqs);
8295
});
8396
});
97+
98+
describe('buildServerPy framework adapters', () => {
99+
const lg = (topic: string): AgUiTopic => ({ topic, pythonDir: `x/${topic}/python`, framework: 'langgraph' });
100+
const maf = (topic: string): AgUiTopic => ({
101+
topic,
102+
pythonDir: `x/${topic}/python`,
103+
framework: 'microsoft-agent-framework',
104+
});
105+
106+
it('langgraph topics import graph and mount via LangGraphAgent, with no MAF bridge import', () => {
107+
const server = buildServerPy([lg('interrupts')]);
108+
expect(server).toContain('from ag_ui_langgraph import add_langgraph_fastapi_endpoint, LangGraphAgent');
109+
expect(server).toContain('from deps.interrupts.src.graph import graph as interrupts_graph');
110+
expect(server).toContain('LangGraphAgent(name="interrupts", graph=interrupts_graph)');
111+
expect(server).not.toContain('agent_framework_ag_ui');
112+
});
113+
114+
it('microsoft-agent-framework topics import agent and mount the agent object directly', () => {
115+
const server = buildServerPy([maf('microsoft-agent-framework')]);
116+
expect(server).toContain('from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint');
117+
expect(server).toContain(
118+
'from deps.microsoft_agent_framework.src.agent import agent as microsoft_agent_framework_agent',
119+
);
120+
expect(server).toContain(
121+
'add_agent_framework_fastapi_endpoint(\n' +
122+
' app,\n' +
123+
' microsoft_agent_framework_agent,\n' +
124+
' path="/agent/microsoft-agent-framework",\n' +
125+
')',
126+
);
127+
// No LangGraph machinery when no langgraph topic is present.
128+
expect(server).not.toContain('ag_ui_langgraph');
129+
expect(server).not.toContain('LangGraphAgent');
130+
});
131+
132+
it('mixed sets emit both bridge imports (langgraph first) and per-topic mounts', () => {
133+
const server = buildServerPy([lg('interrupts'), maf('microsoft-agent-framework')]);
134+
const lgImport = server.indexOf('from ag_ui_langgraph import');
135+
const mafImport = server.indexOf('from agent_framework_ag_ui import');
136+
expect(lgImport).toBeGreaterThan(-1);
137+
expect(mafImport).toBeGreaterThan(lgImport);
138+
expect(server).toContain('path="/agent/interrupts"');
139+
expect(server).toContain('path="/agent/microsoft-agent-framework"');
140+
// Framework routing is per-topic: the langgraph topic must not be
141+
// mounted through the MAF bridge or vice versa.
142+
expect(server).toContain('LangGraphAgent(name="interrupts"');
143+
expect(server).not.toContain('LangGraphAgent(name="microsoft-agent-framework"');
144+
});
145+
});

scripts/generate-ag-ui-deployment-config.ts

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
22
import { resolve } from 'path';
3-
import { capabilities } from '../apps/cockpit/scripts/capability-registry';
3+
import { capabilities, type CapabilityFramework } from '../apps/cockpit/scripts/capability-registry';
44

55
const GENERATED_HEADER = '# GENERATED — do not edit. Source: scripts/generate-ag-ui-deployment-config.ts';
66

@@ -9,11 +9,62 @@ export interface GenerateOptions {
99
outDir: string;
1010
}
1111

12-
interface AgUiTopic {
12+
export interface AgUiTopic {
1313
topic: string;
1414
pythonDir: string;
15+
framework: CapabilityFramework;
1516
}
1617

18+
/**
19+
* Per-framework adapter: how a topic's staged module is imported and mounted
20+
* on the aggregated FastAPI app. Adding a runtime means adding one entry here
21+
* plus a `framework` discriminator on its registry capability — nothing else
22+
* in the generator is framework-aware.
23+
*
24+
* Module contract per framework:
25+
* - langgraph: `deps/<mod>/src/graph.py` exposes a compiled `graph`; mounted
26+
* via ag-ui-langgraph's LangGraphAgent wrapper.
27+
* - microsoft-agent-framework: `deps/<mod>/src/agent.py` exposes an `agent`
28+
* object (agent_framework Agent / AgentFrameworkAgent); the bridge mounts
29+
* the agent object directly — there is no wrapper class.
30+
*/
31+
interface FrameworkAdapter {
32+
/** Module-level import line for the framework's AG-UI bridge package. */
33+
bridgeImport: string;
34+
/** Per-topic import of the staged module's exported object. */
35+
topicImport(mod: string): string;
36+
/** Per-topic FastAPI mount block. */
37+
mount(topic: string, mod: string): string;
38+
}
39+
40+
/**
41+
* Declaration order is emission order for bridge imports in server.py:
42+
* langgraph stays first so a langgraph-only registry generates byte-identical
43+
* output to the pre-adapter generator.
44+
*/
45+
const FRAMEWORK_ADAPTERS: Record<CapabilityFramework, FrameworkAdapter> = {
46+
langgraph: {
47+
bridgeImport: 'from ag_ui_langgraph import add_langgraph_fastapi_endpoint, LangGraphAgent',
48+
topicImport: (mod) => `from deps.${mod}.src.graph import graph as ${mod}_graph`,
49+
mount: (topic, mod) =>
50+
`add_langgraph_fastapi_endpoint(\n` +
51+
` app,\n` +
52+
` LangGraphAgent(name="${topic}", graph=${mod}_graph),\n` +
53+
` path="/agent/${topic}",\n` +
54+
`)`,
55+
},
56+
'microsoft-agent-framework': {
57+
bridgeImport: 'from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint',
58+
topicImport: (mod) => `from deps.${mod}.src.agent import agent as ${mod}_agent`,
59+
mount: (topic, mod) =>
60+
`add_agent_framework_fastapi_endpoint(\n` +
61+
` app,\n` +
62+
` ${mod}_agent,\n` +
63+
` path="/agent/${topic}",\n` +
64+
`)`,
65+
},
66+
};
67+
1768
/**
1869
* A topic is a URL slug and may contain hyphens (e.g. `tool-views`,
1970
* `json-render`). Python package/module names cannot, so the staged deps
@@ -27,8 +78,14 @@ function pyModule(topic: string): string {
2778

2879
function collectTopics(): AgUiTopic[] {
2980
const topics = capabilities
30-
.filter((c) => c.product === 'ag-ui' && c.pythonDir)
31-
.map<AgUiTopic>((c) => ({ topic: c.topic, pythonDir: c.pythonDir! }));
81+
// 'ag-ui' and 'runtimes' products are both AG-UI-served FastAPI backends
82+
// aggregated into the single ag-ui-dev deployment.
83+
.filter((c) => (c.product === 'ag-ui' || c.product === 'runtimes') && c.pythonDir)
84+
.map<AgUiTopic>((c) => ({
85+
topic: c.topic,
86+
pythonDir: c.pythonDir!,
87+
framework: c.framework ?? 'langgraph',
88+
}));
3289
topics.sort((a, b) => a.topic.localeCompare(b.topic));
3390
if (topics.length === 0) {
3491
throw new Error('No ag-ui topics with pythonDir found in capability registry');
@@ -58,19 +115,18 @@ function stageDeps(repoRoot: string, outDir: string, topics: AgUiTopic[]): void
58115
}
59116
}
60117

61-
function buildServerPy(topics: AgUiTopic[]): string {
118+
export function buildServerPy(topics: AgUiTopic[]): string {
119+
const usedFrameworks = (Object.keys(FRAMEWORK_ADAPTERS) as CapabilityFramework[]).filter(
120+
(framework) => topics.some((t) => t.framework === framework),
121+
);
122+
const bridgeImports = usedFrameworks
123+
.map((framework) => FRAMEWORK_ADAPTERS[framework].bridgeImport)
124+
.join('\n');
62125
const imports = topics
63-
.map((t) => `from deps.${pyModule(t.topic)}.src.graph import graph as ${pyModule(t.topic)}_graph`)
126+
.map((t) => FRAMEWORK_ADAPTERS[t.framework].topicImport(pyModule(t.topic)))
64127
.join('\n');
65128
const mounts = topics
66-
.map(
67-
(t) =>
68-
`add_langgraph_fastapi_endpoint(\n` +
69-
` app,\n` +
70-
` LangGraphAgent(name="${t.topic}", graph=${pyModule(t.topic)}_graph),\n` +
71-
` path="/agent/${t.topic}",\n` +
72-
`)`,
73-
)
129+
.map((t) => FRAMEWORK_ADAPTERS[t.framework].mount(t.topic, pyModule(t.topic)))
74130
.join('\n');
75131
return `${GENERATED_HEADER}
76132
# Multi-topic AG-UI FastAPI server. Aggregates each cockpit/ag-ui/*/python topic
@@ -79,7 +135,7 @@ function buildServerPy(topics: AgUiTopic[]): string {
79135
import os
80136
from fastapi import FastAPI, Request
81137
from fastapi.responses import JSONResponse
82-
from ag_ui_langgraph import add_langgraph_fastapi_endpoint, LangGraphAgent
138+
${bridgeImports}
83139
84140
${imports}
85141

scripts/generate-shared-deployment-config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ rmSync(stagedDependenciesDir, { recursive: true, force: true });
6464
mkdirSync(stagedDependenciesDir, { recursive: true });
6565

6666
for (const capability of capabilities) {
67-
if (!capability.pythonDir || capability.product === 'ag-ui') {
68-
// No-Python caps have nothing to deploy. ag-ui caps DO have a pythonDir
69-
// (uvicorn ag-ui-langgraph FastAPI apps) but deploy to Railway via
67+
if (!capability.pythonDir || capability.product === 'ag-ui' || capability.product === 'runtimes') {
68+
// No-Python caps have nothing to deploy. ag-ui and runtimes caps DO have
69+
// a pythonDir (uvicorn AG-UI FastAPI apps) but deploy to Railway via
7070
// generate-ag-ui-deployment-config.ts — they ship no langgraph.json.
7171
continue;
7272
}

0 commit comments

Comments
 (0)