diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index b40600e21..33997c9e7 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -104,6 +104,8 @@ export interface BrowserModeOptions { workingDir: string; project: AgentCoreProjectSpec; port: number; + /** Whether `port` was set explicitly via -p/--port (used as the agent base port instead of uiPort+1) */ + portExplicit?: boolean; agentName?: string; harnessName?: string; /** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */ @@ -152,7 +154,7 @@ export async function launchBrowserDev(): Promise { } export async function runBrowserMode(opts: BrowserModeOptions): Promise { - const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts; + const { workingDir, project, port, portExplicit, agentName, harnessName, otelEnvVars = {}, collector } = opts; const configRoot = findConfigRoot(workingDir); // Browser mode serves multiple agents; we don't know which agent will be @@ -236,6 +238,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { harnesses: harnessInfoList, selectedAgent: agentName, selectedHarness: harnessName, + agentBasePort: portExplicit ? port : undefined, envVars: mergedEnvVars, getEnvVars: async () => { const { envVars: freshEnvVars } = await loadDevEnv(workingDir); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index e680899d7..2ac7f046d 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -171,7 +171,11 @@ export const registerDev = (program: Command) => { .alias('d') .description(COMMAND_DESCRIPTIONS.dev) .argument('[prompt]', 'Send a prompt to a running dev server [non-interactive]') - .option('-p, --port ', 'Port for development server', '8080') + .option( + '-p, --port ', + 'Port for development server. Used as-is when set explicitly; the default is offset by the runtime index in multi-runtime projects.', + '8080' + ) .option('-r, --runtime ', 'Runtime to run or invoke (required if multiple runtimes)') .option('-s, --stream', 'Stream response when invoking [non-interactive]') .option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]') @@ -188,9 +192,11 @@ export const registerDev = (program: Command) => { .option('-b, --no-browser', 'Use terminal TUI instead of web-based chat UI') .option('--no-traces', 'Disable local OTEL trace collection') - .action(async (positionalPrompt: string | undefined, opts) => { + .action(async (positionalPrompt: string | undefined, opts, command) => { try { const port = parseInt(opts.port, 10); + const portSource = command.getOptionValueSource('port'); + const portExplicit = portSource === 'cli' || portSource === 'env'; // Parse custom headers let headers: Record | undefined; @@ -259,7 +265,7 @@ export const registerDev = (program: Command) => { let invokePort = port; let targetAgent = invokeProject?.runtimes[0]; if (opts.runtime && invokeProject) { - invokePort = getAgentPort(invokeProject, opts.runtime, port); + invokePort = getAgentPort(invokeProject, opts.runtime, port, portExplicit); targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime); } else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) { const names = invokeProject.runtimes.map(a => a.name).join(', '); @@ -399,7 +405,10 @@ export const registerDev = (program: Command) => { const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port); + const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port, portExplicit); + if (!isA2A && !isMcp && !portExplicit && fixedPort !== port) { + console.log(`Port ${port} in use as base, using ${fixedPort} for ${config.agentName}`); + } const actualPort = await findAvailablePort(fixedPort); if ((isA2A || isMcp) && actualPort !== fixedPort) { throw new ValidationError( @@ -488,6 +497,7 @@ export const registerDev = (program: Command) => { }} workingDir={workingDir} port={port} + portExplicit={portExplicit} agentName={opts.runtime} headers={headers} skipDeploy={opts.skipDeploy} @@ -533,6 +543,7 @@ export const registerDev = (program: Command) => { workingDir, project, port, + portExplicit, agentName: pickerResult.agentName, harnessName: pickerResult.harnessName, otelEnvVars, @@ -551,6 +562,7 @@ export const registerDev = (program: Command) => { workingDir, project, port, + portExplicit, agentName: opts.runtime, otelEnvVars, collector, diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index a8e2289b3..6dbc25885 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -528,10 +528,54 @@ describe('getAgentPort', () => { payments: [], }; + // Default (implicit) port: offset by runtime index so parallel runtimes differ. expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); expect(getAgentPort(project, 'Agent2', 8080)).toBe(8081); }); + it('honors an explicit port literally with no index offset', () => { + const project: AgentCoreProjectSpec = { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'AgentA', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/a'), + protocol: 'HTTP', + }, + { + name: 'AgentB', + build: 'CodeZip', + runtimeVersion: 'PYTHON_3_12', + entrypoint: filePath('main.py'), + codeLocation: dirPath('./agents/b'), + protocol: 'HTTP', + }, + ], + memories: [], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + harnesses: [], + datasets: [], + payments: [], + }; + + // Explicit -p: 2nd runtime resolves to the literal value (8788), not 8789. + expect(getAgentPort(project, 'AgentB', 8788, true)).toBe(8788); + // Default -p: 2nd runtime still resolves to base + index (8789). + expect(getAgentPort(project, 'AgentB', 8788, false)).toBe(8789); + }); + it('returns basePort when agent not found', () => { const project: AgentCoreProjectSpec = { name: 'TestProject', diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index 95b855124..37cd4c48d 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -66,11 +66,22 @@ export function getDevSupportedAgents(project: AgentCoreProjectSpec | null): Age } /** - * Get the port for a specific agent based on its index in the project. - * Base port + agent index = actual port + * Resolve the port for a specific agent. + * + * - When the user supplied `-p`/`--port` explicitly (`explicit === true`), the + * port is honored literally with NO index offset, so `dev -r AgentB -p 8788` + * binds exactly 8788 regardless of AgentB's position in the project. + * - When the port is the default (`explicit === false`), the agent's index in + * the project is added so parallel runtimes bind distinct ports + * (basePort, basePort + 1, ...). */ -export function getAgentPort(project: AgentCoreProjectSpec | null, agentName: string, basePort: number): number { - if (!project) return basePort; +export function getAgentPort( + project: AgentCoreProjectSpec | null, + agentName: string, + basePort: number, + explicit = false +): number { + if (explicit || !project) return basePort; const index = project.runtimes.findIndex(a => a.name === agentName); return index >= 0 ? basePort + index : basePort; } diff --git a/src/cli/operations/dev/web-ui/handlers/start.ts b/src/cli/operations/dev/web-ui/handlers/start.ts index 52c9c129e..dcb95fab4 100644 --- a/src/cli/operations/dev/web-ui/handlers/start.ts +++ b/src/cli/operations/dev/web-ui/handlers/start.ts @@ -100,7 +100,18 @@ async function doStartAgent( const isMCP = config.protocol === 'MCP'; const fixedPort = isA2A ? 9000 : isMCP ? 8000 : undefined; const isTsHttp = !config.isPython && config.protocol === 'HTTP'; - const targetPort = fixedPort ?? ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0); + // When -p is set explicitly, honor it for the selected runtime (no offset) so the + // web UI matches the --logs and TUI paths; other concurrently-served runtimes are + // offset relative to it. Otherwise derive HTTP ports from uiPort + 1 + index. + const selectedIndex = ctx.options.selectedAgent + ? ctx.options.agents.findIndex(a => a.name === ctx.options.selectedAgent) + : -1; + const safeAgentIndex = agentIndex >= 0 ? agentIndex : 0; + const targetPort = + fixedPort ?? + (ctx.options.agentBasePort !== undefined + ? ctx.options.agentBasePort + (safeAgentIndex - (selectedIndex >= 0 ? selectedIndex : 0)) + : ctx.options.uiPort + 1 + safeAgentIndex); const agentPort = await findAvailablePort(targetPort); if (fixedPort && agentPort !== fixedPort) { const reason = isA2A ? 'A2A agents require port 9000.' : 'MCP agents require port 8000 (FastMCP default).'; diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts index dc59be4a6..e8f2b1a61 100644 --- a/src/cli/operations/dev/web-ui/web-server.ts +++ b/src/cli/operations/dev/web-ui/web-server.ts @@ -146,6 +146,12 @@ export interface WebUIOptions { uiPort: number; /** Available agents (metadata only — servers are started on demand) */ agents: AgentInfo[]; + /** + * Explicit agent base port from -p/--port. When set, HTTP agents bind off this + * value (base + index) instead of uiPort + 1 + index, so an explicit `-p` is + * honored in the web UI consistently with the --logs and TUI paths. + */ + agentBasePort?: number; /** Deployed harnesses available for invocation (metadata only — no local server needed) */ harnesses?: HarnessInfo[]; /** Dev config factory — called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */ diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 7e5d6964b..5444a7d91 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -13,6 +13,7 @@ import { createDevServer, fetchA2AAgentCard, findAvailablePort, + getAgentPort, getDevConfig, getEndpointUrl, invokeA2AStreaming, @@ -47,6 +48,7 @@ const MAX_LOG_ENTRIES = 50; export function useDevServer(options: { workingDir: string; port: number; + portExplicit?: boolean; agentName?: string; onReady?: () => void; headers?: Record; @@ -154,7 +156,10 @@ export function useDevServer(options: { // A2A servers always use port 9000, MCP servers use port 8000 (framework defaults, not configurable via env) const isA2A = config.protocol === 'A2A'; const isMcp = config.protocol === 'MCP'; - const fixedPort = isA2A ? 9000 : isMcp ? 8000 : targetPort; + // HTTP: honor an explicit -p literally; otherwise offset by the runtime index + // so parallel runtimes bind distinct ports (consistent with the --logs path). + const httpPort = getAgentPort(project, config.agentName, targetPort, options.portExplicit); + const fixedPort = isA2A ? 9000 : isMcp ? 8000 : httpPort; // On restart, reuse the same port. On initial start, find an available port. // If restart times out waiting for port, fall back to finding a new one. @@ -246,7 +251,9 @@ export function useDevServer(options: { config?.module, config?.directory, config?.isPython, + project, options.workingDir, + options.portExplicit, targetPort, restartTrigger, envVars, diff --git a/src/cli/tui/screens/dev/DevScreen.tsx b/src/cli/tui/screens/dev/DevScreen.tsx index 8dbc66c75..bf51899cf 100644 --- a/src/cli/tui/screens/dev/DevScreen.tsx +++ b/src/cli/tui/screens/dev/DevScreen.tsx @@ -23,6 +23,8 @@ interface DevScreenProps { onBack: () => void; workingDir?: string; port?: number; + /** Whether `port` was set explicitly via -p/--port (no index offset applied when true) */ + portExplicit?: boolean; /** Pre-selected agent name (from CLI --agent flag) */ agentName?: string; /** Custom headers to forward to the agent on every invocation */ @@ -249,6 +251,7 @@ export function DevScreen(props: DevScreenProps) { } = useDevServer({ workingDir, port: props.port ?? 8080, + portExplicit: props.portExplicit, agentName: selectedAgentName, onReady: onServerReady, headers: props.headers,