Skip to content

Commit 1a91c99

Browse files
authored
fix(dev): honor explicit --port literally instead of silently offsetting by runtime index (#1079) (#1661)
* fix(dev): honor explicit --port literally instead of silently offsetting by runtime index When running multiple `agentcore dev` runtimes, an explicit -p/--port was silently offset by the runtime's index in agentcore.json (basePort + index), so `agentcore dev -r AgentB -p 8788` bound 8789 with no explanation. Clients targeting http://localhost:<-p>/invocations silently hit the wrong port. Use Commander's getOptionValueSource to distinguish explicit vs default -p: honor an explicit --port literally across the --logs, web-ui, and TUI paths; when -p is implicit (default), keep the index offset but log it (mirroring the existing findAvailablePort shift message) and document it in --help. invoke continues to target whatever port the server bound, made consistent with the chosen server semantics. Scope: Surgical, unit-test-verified. tsc --noEmit clean, eslint clean (no new warnings), 1002/1002 unit tests pass across src/cli/operations/dev and src/cli/tui including the updated and new getAgentPort tests. The only failing tests (4 in dev.test.ts) are pre-existing CLI-spawn failures that fail identically on the clean baseline (require a built dist; environmental, unrelated to this change). The web-ui path is a structurally multi-agent server, so for the explicit -p case the SELECTED runtime binds exactly -p while concurrently-served runtimes are offset relative to it -- the minimal way to honor explicit -p there without redesigning the multi-agent port model. * fix(dev): honor explicit --port literally instead of silently offsetting by runtime index (#1079) * style(dev): format command.tsx fixedPort ternary with prettier * fix(dev): fail fast on explicit --port conflict, clarify offset log, instrument port_explicit - throw/error instead of silently rebinding when an explicit -p is taken across the --logs, TUI, and web-ui start paths (the silent-shift behavior #1079 removes) - reword the implicit-offset log to name the runtime index and how to override (no longer implies a port conflict) - web-ui: honor explicit -p literally only for the selected runtime; other runtimes keep the default uiPort+1+index allocation (no ports below the base) - extract resolveAgentTargetPort and unit-test it - add optional port_explicit telemetry attr to DevAttrs
1 parent 5e9f6d0 commit 1a91c99

10 files changed

Lines changed: 252 additions & 11 deletions

File tree

src/cli/commands/dev/browser-mode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export interface BrowserModeOptions {
105105
workingDir: string;
106106
project: AgentCoreProjectSpec;
107107
port: number;
108+
/** Whether `port` was set explicitly via -p/--port (the selected runtime then binds it literally) */
109+
portExplicit?: boolean;
108110
agentName?: string;
109111
harnessName?: string;
110112
/** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */
@@ -153,7 +155,7 @@ export async function launchBrowserDev(): Promise<void> {
153155
}
154156

155157
export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
156-
const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts;
158+
const { workingDir, project, port, portExplicit, agentName, harnessName, otelEnvVars = {}, collector } = opts;
157159

158160
const configRoot = findConfigRoot(workingDir);
159161
// Browser mode serves multiple agents; we don't know which agent will be
@@ -237,6 +239,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise<void> {
237239
harnesses: harnessInfoList,
238240
selectedAgent: agentName,
239241
selectedHarness: harnessName,
242+
agentBasePort: portExplicit ? port : undefined,
240243
envVars: mergedEnvVars,
241244
getEnvVars: async () => {
242245
const { envVars: freshEnvVars } = await loadDevEnv(workingDir);

src/cli/commands/dev/command.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,11 @@ export const registerDev = (program: Command) => {
171171
.alias('d')
172172
.description(COMMAND_DESCRIPTIONS.dev)
173173
.argument('[prompt]', 'Send a prompt to a running dev server [non-interactive]')
174-
.option('-p, --port <port>', 'Port for development server', '8080')
174+
.option(
175+
'-p, --port <port>',
176+
'Port for development server. Used as-is when set explicitly; the default is offset by the runtime index in multi-runtime projects.',
177+
'8080'
178+
)
175179
.option('-r, --runtime <name>', 'Runtime to run or invoke (required if multiple runtimes)')
176180
.option('-s, --stream', 'Stream response when invoking [non-interactive]')
177181
.option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]')
@@ -188,9 +192,11 @@ export const registerDev = (program: Command) => {
188192
.option('-b, --no-browser', 'Use terminal TUI instead of web-based chat UI')
189193
.option('--no-traces', 'Disable local OTEL trace collection')
190194

191-
.action(async (positionalPrompt: string | undefined, opts) => {
195+
.action(async (positionalPrompt: string | undefined, opts, command) => {
192196
try {
193197
const port = parseInt(opts.port, 10);
198+
const portSource = command.getOptionValueSource('port');
199+
const portExplicit = portSource === 'cli' || portSource === 'env';
194200

195201
// Parse custom headers
196202
let headers: Record<string, string> | undefined;
@@ -259,7 +265,7 @@ export const registerDev = (program: Command) => {
259265
let invokePort = port;
260266
let targetAgent = invokeProject?.runtimes[0];
261267
if (opts.runtime && invokeProject) {
262-
invokePort = getAgentPort(invokeProject, opts.runtime, port);
268+
invokePort = getAgentPort(invokeProject, opts.runtime, port, portExplicit);
263269
targetAgent = invokeProject.runtimes.find(a => a.name === opts.runtime);
264270
} else if (invokeProject && invokeProject.runtimes.length > 1 && !opts.runtime) {
265271
const names = invokeProject.runtimes.map(a => a.name).join(', ');
@@ -316,6 +322,8 @@ export const registerDev = (program: Command) => {
316322
invoke_count: 0,
317323
},
318324
async recorder => {
325+
recorder.set({ port_explicit: portExplicit });
326+
319327
const project = await loadProjectConfig(workingDir);
320328
if (!project) {
321329
throw new NoProjectError();
@@ -399,13 +407,31 @@ export const registerDev = (program: Command) => {
399407

400408
const isA2A = config.protocol === 'A2A';
401409
const isMcp = config.protocol === 'MCP';
402-
const fixedPort = isA2A ? 9000 : isMcp ? 8000 : getAgentPort(project, config.agentName, port);
410+
const isHttp = !isA2A && !isMcp;
411+
const fixedPort = isA2A
412+
? 9000
413+
: isMcp
414+
? 8000
415+
: getAgentPort(project, config.agentName, port, portExplicit);
416+
if (isHttp && !portExplicit && fixedPort !== port) {
417+
const idx = project.runtimes.findIndex(a => a.name === config.agentName);
418+
console.log(
419+
`Runtime "${config.agentName}" is at index ${idx}; using port ${fixedPort} (pass --port ${fixedPort} to override).`
420+
);
421+
}
403422
const actualPort = await findAvailablePort(fixedPort);
404423
if ((isA2A || isMcp) && actualPort !== fixedPort) {
405424
throw new ValidationError(
406425
`Port ${fixedPort} is in use. ${config.protocol} agents require port ${fixedPort}.`
407426
);
408427
}
428+
// An explicit -p must be honored literally; if it's taken, fail fast instead of
429+
// silently rebinding to a different port (the silent-shift behavior #1079 removes).
430+
if (isHttp && portExplicit && actualPort !== fixedPort) {
431+
throw new ValidationError(
432+
`Port ${fixedPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`
433+
);
434+
}
409435

410436
// Deploy resources before starting dev server (harness projects)
411437
if (!opts.skipDeploy && hasHarnesses) {
@@ -488,6 +514,7 @@ export const registerDev = (program: Command) => {
488514
}}
489515
workingDir={workingDir}
490516
port={port}
517+
portExplicit={portExplicit}
491518
agentName={opts.runtime}
492519
headers={headers}
493520
skipDeploy={opts.skipDeploy}
@@ -533,6 +560,7 @@ export const registerDev = (program: Command) => {
533560
workingDir,
534561
project,
535562
port,
563+
portExplicit,
536564
agentName: pickerResult.agentName,
537565
harnessName: pickerResult.harnessName,
538566
otelEnvVars,
@@ -551,6 +579,7 @@ export const registerDev = (program: Command) => {
551579
workingDir,
552580
project,
553581
port,
582+
portExplicit,
554583
agentName: opts.runtime,
555584
otelEnvVars,
556585
collector,

src/cli/operations/dev/__tests__/config.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,10 +528,54 @@ describe('getAgentPort', () => {
528528
payments: [],
529529
};
530530

531+
// Default (implicit) port: offset by runtime index so parallel runtimes differ.
531532
expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080);
532533
expect(getAgentPort(project, 'Agent2', 8080)).toBe(8081);
533534
});
534535

536+
it('honors an explicit port literally with no index offset', () => {
537+
const project: AgentCoreProjectSpec = {
538+
name: 'TestProject',
539+
version: 1,
540+
managedBy: 'CDK' as const,
541+
runtimes: [
542+
{
543+
name: 'AgentA',
544+
build: 'CodeZip',
545+
runtimeVersion: 'PYTHON_3_12',
546+
entrypoint: filePath('main.py'),
547+
codeLocation: dirPath('./agents/a'),
548+
protocol: 'HTTP',
549+
},
550+
{
551+
name: 'AgentB',
552+
build: 'CodeZip',
553+
runtimeVersion: 'PYTHON_3_12',
554+
entrypoint: filePath('main.py'),
555+
codeLocation: dirPath('./agents/b'),
556+
protocol: 'HTTP',
557+
},
558+
],
559+
memories: [],
560+
knowledgeBases: [],
561+
credentials: [],
562+
evaluators: [],
563+
onlineEvalConfigs: [],
564+
agentCoreGateways: [],
565+
policyEngines: [],
566+
configBundles: [],
567+
abTests: [],
568+
harnesses: [],
569+
datasets: [],
570+
payments: [],
571+
};
572+
573+
// Explicit -p: 2nd runtime resolves to the literal value (8788), not 8789.
574+
expect(getAgentPort(project, 'AgentB', 8788, true)).toBe(8788);
575+
// Default -p: 2nd runtime still resolves to base + index (8789).
576+
expect(getAgentPort(project, 'AgentB', 8788, false)).toBe(8789);
577+
});
578+
535579
it('returns basePort when agent not found', () => {
536580
const project: AgentCoreProjectSpec = {
537581
name: 'TestProject',

src/cli/operations/dev/config.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,22 @@ export function getDevSupportedAgents(project: AgentCoreProjectSpec | null): Age
6666
}
6767

6868
/**
69-
* Get the port for a specific agent based on its index in the project.
70-
* Base port + agent index = actual port
69+
* Resolve the port for a specific agent.
70+
*
71+
* - When the user supplied `-p`/`--port` explicitly (`explicit === true`), the
72+
* port is honored literally with NO index offset, so `dev -r AgentB -p 8788`
73+
* binds exactly 8788 regardless of AgentB's position in the project.
74+
* - When the port is the default (`explicit === false`), the agent's index in
75+
* the project is added so parallel runtimes bind distinct ports
76+
* (basePort, basePort + 1, ...).
7177
*/
72-
export function getAgentPort(project: AgentCoreProjectSpec | null, agentName: string, basePort: number): number {
73-
if (!project) return basePort;
78+
export function getAgentPort(
79+
project: AgentCoreProjectSpec | null,
80+
agentName: string,
81+
basePort: number,
82+
explicit = false
83+
): number {
84+
if (explicit || !project) return basePort;
7485
const index = project.runtimes.findIndex(a => a.name === agentName);
7586
return index >= 0 ? basePort + index : basePort;
7687
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { resolveAgentTargetPort } from '../handlers/start.js';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('resolveAgentTargetPort', () => {
5+
const base = { uiPort: 7777 };
6+
7+
it('uses uiPort + 1 + index for HTTP runtimes when no explicit -p is set', () => {
8+
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'A', agentIndex: 0 })).toBe(7778);
9+
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'B', agentIndex: 1 })).toBe(7779);
10+
});
11+
12+
it('falls back to index 0 when the agent is not found', () => {
13+
expect(resolveAgentTargetPort({ ...base, protocol: 'HTTP', agentName: 'missing', agentIndex: -1 })).toBe(7778);
14+
});
15+
16+
it('uses framework-fixed ports for A2A and MCP regardless of -p', () => {
17+
expect(
18+
resolveAgentTargetPort({
19+
...base,
20+
protocol: 'A2A',
21+
agentName: 'A',
22+
agentIndex: 3,
23+
agentBasePort: 8788,
24+
selectedAgent: 'A',
25+
})
26+
).toBe(9000);
27+
expect(
28+
resolveAgentTargetPort({
29+
...base,
30+
protocol: 'MCP',
31+
agentName: 'A',
32+
agentIndex: 3,
33+
agentBasePort: 8788,
34+
selectedAgent: 'A',
35+
})
36+
).toBe(8000);
37+
});
38+
39+
it('honors an explicit -p literally for the selected runtime (no offset)', () => {
40+
expect(
41+
resolveAgentTargetPort({
42+
...base,
43+
protocol: 'HTTP',
44+
agentName: 'AgentB',
45+
agentIndex: 1,
46+
agentBasePort: 8788,
47+
selectedAgent: 'AgentB',
48+
})
49+
).toBe(8788);
50+
});
51+
52+
it('keeps the default allocation for non-selected runtimes even when -p is explicit', () => {
53+
// AgentA (index 0) is not selected, so it never binds below the requested -p.
54+
expect(
55+
resolveAgentTargetPort({
56+
...base,
57+
protocol: 'HTTP',
58+
agentName: 'AgentA',
59+
agentIndex: 0,
60+
agentBasePort: 8788,
61+
selectedAgent: 'AgentB',
62+
})
63+
).toBe(7778);
64+
// AgentC (index 2) likewise uses the default base, not a port derived from -p.
65+
expect(
66+
resolveAgentTargetPort({
67+
...base,
68+
protocol: 'HTTP',
69+
agentName: 'AgentC',
70+
agentIndex: 2,
71+
agentBasePort: 8788,
72+
selectedAgent: 'AgentB',
73+
})
74+
).toBe(7780);
75+
});
76+
});

src/cli/operations/dev/web-ui/handlers/start.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,35 @@ export async function handleStart(
7070
}
7171
}
7272

73+
/**
74+
* Resolve the target port a web-UI-served agent should bind to.
75+
*
76+
* - A2A/MCP agents use their framework-fixed ports (9000 / 8000).
77+
* - When `-p`/`--port` was set explicitly (`agentBasePort !== undefined`), the
78+
* *selected* runtime is honored literally (binds exactly `agentBasePort`, no
79+
* offset). All other runtimes fall back to the default `uiPort + 1 + index`
80+
* allocation, so an explicit `-p` never produces ports below the requested
81+
* value or for runtimes the user didn't ask for.
82+
* - Otherwise every HTTP runtime is allocated `uiPort + 1 + index`.
83+
*/
84+
export function resolveAgentTargetPort(args: {
85+
protocol: string;
86+
agentName: string;
87+
agentIndex: number;
88+
uiPort: number;
89+
agentBasePort?: number;
90+
selectedAgent?: string;
91+
}): number {
92+
const { protocol, agentName, agentIndex, uiPort, agentBasePort, selectedAgent } = args;
93+
if (protocol === 'A2A') return 9000;
94+
if (protocol === 'MCP') return 8000;
95+
const safeIndex = agentIndex >= 0 ? agentIndex : 0;
96+
if (agentBasePort !== undefined && agentName === selectedAgent) {
97+
return agentBasePort;
98+
}
99+
return uiPort + 1 + safeIndex;
100+
}
101+
73102
/**
74103
* Actually start an agent server. Extracted so the result
75104
* can be shared across concurrent requests via startingAgents.
@@ -100,7 +129,17 @@ async function doStartAgent(
100129
const isMCP = config.protocol === 'MCP';
101130
const fixedPort = isA2A ? 9000 : isMCP ? 8000 : undefined;
102131
const isTsHttp = !config.isPython && config.protocol === 'HTTP';
103-
const targetPort = fixedPort ?? ctx.options.uiPort + 1 + (agentIndex >= 0 ? agentIndex : 0);
132+
const targetPort = resolveAgentTargetPort({
133+
protocol: config.protocol,
134+
agentName,
135+
agentIndex,
136+
uiPort: ctx.options.uiPort,
137+
agentBasePort: ctx.options.agentBasePort,
138+
selectedAgent: ctx.options.selectedAgent,
139+
});
140+
// An explicit -p must be honored literally for the selected runtime; if it's taken,
141+
// fail fast instead of silently rebinding (the silent-shift behavior #1079 removes).
142+
const portIsExplicit = ctx.options.agentBasePort !== undefined && agentName === ctx.options.selectedAgent;
104143
const agentPort = await findAvailablePort(targetPort);
105144
if (fixedPort && agentPort !== fixedPort) {
106145
const reason = isA2A ? 'A2A agents require port 9000.' : 'MCP agents require port 8000 (FastMCP default).';
@@ -111,6 +150,14 @@ async function doStartAgent(
111150
error: `Port ${fixedPort} is in use. ${reason}`,
112151
};
113152
}
153+
if (portIsExplicit && agentPort !== targetPort) {
154+
return {
155+
success: false,
156+
name: agentName,
157+
port: 0,
158+
error: `Port ${targetPort} is in use. Free it or pass a different --port (no port is chosen automatically when --port is set explicitly).`,
159+
};
160+
}
114161
if (agentPort !== targetPort) {
115162
onLog?.('info', `[${agentName}] Port ${targetPort} in use, using ${agentPort}`);
116163
}

src/cli/operations/dev/web-ui/web-server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,14 @@ export interface WebUIOptions {
146146
uiPort: number;
147147
/** Available agents (metadata only — servers are started on demand) */
148148
agents: AgentInfo[];
149+
/**
150+
* Explicit agent base port from -p/--port. When set, the selected runtime
151+
* ({@link selectedAgent}) is honored literally (binds exactly this value, no
152+
* offset); other HTTP runtimes keep the default uiPort + 1 + index allocation.
153+
* This keeps an explicit `-p` consistent with the --logs and TUI paths without
154+
* remapping runtimes the user didn't ask for.
155+
*/
156+
agentBasePort?: number;
149157
/** Deployed harnesses available for invocation (metadata only — no local server needed) */
150158
harnesses?: HarnessInfo[];
151159
/** Dev config factory — called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */

src/cli/telemetry/schemas/command-run.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ const DevAttrs = safeSchema({
129129
has_stream: z.boolean(),
130130
agent_protocol: AgentProtocol.optional(),
131131
invoke_count: Count,
132+
// Whether -p/--port was set explicitly (vs the default). Lets us track adoption
133+
// of the explicit-port-honored behavior introduced for #1079.
134+
port_explicit: z.boolean().optional(),
132135
});
133136

134137
const InvokeAttrs = safeSchema({

0 commit comments

Comments
 (0)