Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/cli/commands/dev/browser-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -152,7 +154,7 @@ export async function launchBrowserDev(): Promise<void> {
}

export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
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
Expand Down Expand Up @@ -236,6 +238,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
harnesses: harnessInfoList,
selectedAgent: agentName,
selectedHarness: harnessName,
agentBasePort: portExplicit ? port : undefined,
envVars: mergedEnvVars,
getEnvVars: async () => {
const { envVars: freshEnvVars } = await loadDevEnv(workingDir);
Expand Down
20 changes: 16 additions & 4 deletions src/cli/commands/dev/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>', 'Port for development server', '8080')
.option(
'-p, --port <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 <name>', '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]')
Expand All @@ -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<string, string> | undefined;
Expand Down Expand Up @@ -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(', ');
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -488,6 +497,7 @@ export const registerDev = (program: Command) => {
}}
workingDir={workingDir}
port={port}
portExplicit={portExplicit}
agentName={opts.runtime}
headers={headers}
skipDeploy={opts.skipDeploy}
Expand Down Expand Up @@ -533,6 +543,7 @@ export const registerDev = (program: Command) => {
workingDir,
project,
port,
portExplicit,
agentName: pickerResult.agentName,
harnessName: pickerResult.harnessName,
otelEnvVars,
Expand All @@ -551,6 +562,7 @@ export const registerDev = (program: Command) => {
workingDir,
project,
port,
portExplicit,
agentName: opts.runtime,
otelEnvVars,
collector,
Expand Down
44 changes: 44 additions & 0 deletions src/cli/operations/dev/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 15 additions & 4 deletions src/cli/operations/dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion src/cli/operations/dev/web-ui/handlers/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).';
Expand Down
6 changes: 6 additions & 0 deletions src/cli/operations/dev/web-ui/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
9 changes: 8 additions & 1 deletion src/cli/tui/hooks/useDevServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createDevServer,
fetchA2AAgentCard,
findAvailablePort,
getAgentPort,
getDevConfig,
getEndpointUrl,
invokeA2AStreaming,
Expand Down Expand Up @@ -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<string, string>;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -246,7 +251,9 @@ export function useDevServer(options: {
config?.module,
config?.directory,
config?.isPython,
project,
options.workingDir,
options.portExplicit,
targetPort,
restartTrigger,
envVars,
Expand Down
3 changes: 3 additions & 0 deletions src/cli/tui/screens/dev/DevScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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,
Expand Down
Loading