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 (the selected runtime then binds it literally) */
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
37 changes: 33 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 @@ -316,6 +322,8 @@ export const registerDev = (program: Command) => {
invoke_count: 0,
},
async recorder => {
recorder.set({ port_explicit: portExplicit });

const project = await loadProjectConfig(workingDir);
if (!project) {
throw new NoProjectError();
Expand Down Expand Up @@ -399,13 +407,31 @@ 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 isHttp = !isA2A && !isMcp;
const fixedPort = isA2A
? 9000
: isMcp
? 8000
: getAgentPort(project, config.agentName, port, portExplicit);
if (isHttp && !portExplicit && fixedPort !== port) {
const idx = project.runtimes.findIndex(a => a.name === config.agentName);
console.log(
`Runtime "${config.agentName}" is at index ${idx}; using port ${fixedPort} (pass --port ${fixedPort} to override).`
);
}
const actualPort = await findAvailablePort(fixedPort);
if ((isA2A || isMcp) && actualPort !== fixedPort) {
throw new ValidationError(
`Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`
);
}
// An explicit -p must be honored literally; if it's taken, fail fast instead of
// silently rebinding to a different port (the silent-shift behavior #1079 removes).
if (isHttp && portExplicit && actualPort !== fixedPort) {
throw new ValidationError(
`Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
);
}

// Deploy resources before starting dev server (harness projects)
if (!opts.skipDeploy && hasHarnesses) {
Expand Down Expand Up @@ -488,6 +514,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 +560,7 @@ export const registerDev = (program: Command) => {
workingDir,
project,
port,
portExplicit,
agentName: pickerResult.agentName,
harnessName: pickerResult.harnessName,
otelEnvVars,
Expand All @@ -551,6 +579,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
76 changes: 76 additions & 0 deletions src/cli/operations/dev/web-ui/__tests__/start-port.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { resolveAgentTargetPort } from '../handlers/start.js';
import { describe, expect, it } from 'vitest';

describe('resolveAgentTargetPort', () => {
const base = { uiPort: 7777 };

it('uses uiPort + 1 + index for HTTP runtimes when no explicit -p is set', () => {
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'A', agentIndex: 0 })).toBe(7778);
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'B', agentIndex: 1 })).toBe(7779);
});

it('falls back to index 0 when the agent is not found', () => {
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'missing', agentIndex: -1 })).toBe(7778);
});

it('uses framework-fixed ports for A2A and MCP regardless of -p', () => {
expect(
resolveAgentTargetPort({
...base,
protocol: 'A2A',
agentName: 'A',
agentIndex: 3,
agentBasePort: 8788,
selectedAgent: 'A',
})
).toBe(9000);
expect(
resolveAgentTargetPort({
...base,
protocol: 'MCP',
agentName: 'A',
agentIndex: 3,
agentBasePort: 8788,
selectedAgent: 'A',
})
).toBe(8000);
});

it('honors an explicit -p literally for the selected runtime (no offset)', () => {
expect(
resolveAgentTargetPort({
...base,
protocol: 'HTTP',
agentName: 'AgentB',
agentIndex: 1,
agentBasePort: 8788,
selectedAgent: 'AgentB',
})
).toBe(8788);
});

it('keeps the default allocation for non-selected runtimes even when -p is explicit', () => {
// AgentA (index 0) is not selected, so it never binds below the requested -p.
expect(
resolveAgentTargetPort({
...base,
protocol: 'HTTP',
agentName: 'AgentA',
agentIndex: 0,
agentBasePort: 8788,
selectedAgent: 'AgentB',
})
).toBe(7778);
// AgentC (index 2) likewise uses the default base, not a port derived from -p.
expect(
resolveAgentTargetPort({
...base,
protocol: 'HTTP',
agentName: 'AgentC',
agentIndex: 2,
agentBasePort: 8788,
selectedAgent: 'AgentB',
})
).toBe(7780);
});
});
49 changes: 48 additions & 1 deletion src/cli/operations/dev/web-ui/handlers/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ export async function handleStart(
}
}

/**
* Resolve the target port a web-UI-served agent should bind to.
*
* - A2A/MCP agents use their framework-fixed ports (9000 / 8000).
* - When `-p`/`--port` was set explicitly (`agentBasePort !== undefined`), the
* *selected* runtime is honored literally (binds exactly `agentBasePort`, no
* offset). All other runtimes fall back to the default `uiPort + 1 + index`
* allocation, so an explicit `-p` never produces ports below the requested
* value or for runtimes the user didn't ask for.
* - Otherwise every HTTP runtime is allocated `uiPort + 1 + index`.
*/
export function resolveAgentTargetPort(args: {
protocol: string;
agentName: string;
agentIndex: number;
uiPort: number;
agentBasePort?: number;
selectedAgent?: string;
}): number {
const { protocol, agentName, agentIndex, uiPort, agentBasePort, selectedAgent } = args;
if (protocol === 'A2A') return 9000;
if (protocol === 'MCP') return 8000;
const safeIndex = agentIndex >= 0 ? agentIndex : 0;
if (agentBasePort !== undefined && agentName === selectedAgent) {
return agentBasePort;
}
return uiPort + 1 + safeIndex;
}

/**
* Actually start an agent server. Extracted so the result
* can be shared across concurrent requests via startingAgents.
Expand Down Expand Up @@ -100,7 +129,17 @@ 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);
const targetPort = resolveAgentTargetPort({
protocol: config.protocol,
agentName,
agentIndex,
uiPort: ctx.options.uiPort,
agentBasePort: ctx.options.agentBasePort,
selectedAgent: ctx.options.selectedAgent,
});
// An explicit -p must be honored literally for the selected runtime; if it's taken,
// fail fast instead of silently rebinding (the silent-shift behavior #1079 removes).
const portIsExplicit = ctx.options.agentBasePort !== undefined && agentName === ctx.options.selectedAgent;
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 All @@ -111,6 +150,14 @@ async function doStartAgent(
error: `Port ${fixedPort} is in use. ${reason}`,
};
}
if (portIsExplicit && agentPort !== targetPort) {
return {
success: false,
name: agentName,
port: 0,
error: `Port ${targetPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`,
};
}
if (agentPort !== targetPort) {
onLog?.('info', `[${agentName}] Port ${targetPort} in use, using ${agentPort}`);
}
Expand Down
8 changes: 8 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,14 @@ 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, the selected runtime
* ({@link selectedAgent}) is honored literally (binds exactly this value, no
* offset); other HTTP runtimes keep the default uiPort + 1 + index allocation.
* This keeps an explicit `-p` consistent with the --logs and TUI paths without
* remapping runtimes the user didn't ask for.
*/
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
3 changes: 3 additions & 0 deletions src/cli/telemetry/schemas/command-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ const DevAttrs = safeSchema({
has_stream: z.boolean(),
agent_protocol: AgentProtocol.optional(),
invoke_count: Count,
// Whether -p/--port was set explicitly (vs the default). Lets us track adoption
// of the explicit-port-honored behavior introduced for #1079.
port_explicit: z.boolean().optional(),
});

const InvokeAttrs = safeSchema({
Expand Down
Loading
Loading