Skip to content

Commit 8898535

Browse files
authored
feat(dev): positional prompt arg for invoking dev server (#707)
* feat(dev): add positional prompt arg, remove --invoke flag Replace `agentcore dev --invoke "Hello"` with `agentcore dev "Hello"`. The dev command now accepts an optional positional [prompt] argument that invokes a running dev server directly. The --invoke/-i flag is removed entirely — this is a breaking change for scripts using it. Rejected: keep --invoke as deprecated alias | adds UX confusion with two ways to do the same thing Confidence: high Scope-risk: narrow * feat(dev): auto-start dev server on invoke When running `agentcore dev "Hello"`, the CLI now automatically starts a dev server if none is running, invokes it, and shuts it down after. If a server is already running (via --logs or TUI), it reuses it. - Export waitForServerReady from dev operations barrel - Probe target port before invoking; auto-start if nothing listening - Use Promise.race to bail early if server crashes during startup - Silent startup (no "Starting dev server..." output) - Helpful error when outside project with no server running - Update tests, docs, and command descriptions Constraint: Must not add latency when server is already running Rejected: Always require separate terminal for dev server | poor UX parity with agentcore invoke Confidence: high Scope-risk: narrow * fix(dev): prevent orphaned servers and handle empty prompt Fixes found during bugbash of auto-start feature: - Register process.on('exit') handler to kill auto-started server, ensuring cleanup even when invoke helpers call process.exit(1) - Initialize resolveServerExit with safe default to prevent potential undefined call if Promise executor changes - Add unref() to SIGKILL timer in DevServer.kill() so Node.js exits promptly instead of hanging 2 seconds - Use !== undefined check for prompt so empty string "" enters invoke path instead of falling through to interactive TUI Constraint: process.on('exit') handlers are synchronous; cannot await server shutdown Rejected: Modify invoke helpers to throw instead of process.exit | too many callers to change Confidence: high Scope-risk: narrow Not-tested: rapid consecutive invocations (port TIME_WAIT race) * fix(dev): detect non-agentcore servers on target port After the TCP port probe finds something listening, do a lightweight HTTP GET to the endpoint. If the response is HTML (text/html), the server is not an agentcore dev server — show a clear error instead of sending a POST and dumping raw HTML as the error message. Constraint: Cannot add a /health endpoint without modifying all Python agent templates Rejected: HEAD request probe | some servers return different headers for HEAD vs GET Confidence: high Scope-risk: narrow * fix(dev): reliable server cleanup on SIGINT and non-HTTP port detection - Spawn child processes with detached:true and kill the entire process group (-pid) to clean up uvicorn workers and other grandchild processes on shutdown - Fix SIGKILL fallback that never fired: child.killed is set when the signal is sent, not when the process exits. Use child.exitCode to check actual termination. - Add explicit SIGINT handler in auto-start invoke path so Ctrl+C kills the server before Node exits - Improve port identity probe: detect non-HTTP processes (e.g., raw TCP listeners) by treating probe failures as port conflicts instead of silently falling through - Guard kill() in finally block with try-catch and remove exit listener to prevent leaks Constraint: process.kill(-pid) requires detached:true so child is its own process group leader Rejected: child.killed for exit check | set on signal send, not on actual termination Rejected: process.exit(130) in SIGINT handler | kills Node before child cleanup completes Confidence: high Scope-risk: moderate Not-tested: SIGINT during waitForServerReady phase (before invoke starts) * revert(dev): remove dev-server.ts spawn/kill changes Reverts the detached:true, process group kill, and exitCode changes from dev-server.ts. These affected all dev server modes (TUI, --logs, auto-start) but were only needed for an edge case in auto-start SIGINT cleanup. Too much blast radius for the benefit. The command.tsx fixes (port probe, SIGINT handler, exit listener cleanup) remain — they are scoped to the invoke path only. * fix(dev): simplify invoke to require running server, improve error messages Remove auto-start logic from the invoke path. Users must now start the dev server in a separate terminal with `agentcore dev --logs` before invoking with `agentcore dev "prompt"`. Improve connection error detection by adding isConnectionRefused() helper that checks ConnectionError name, cause chain, and common fetch failure messages instead of only checking err.message for ECONNREFUSED. Constraint: Node fetch wraps ECONNREFUSED in TypeError cause chain Rejected: Auto-start server on invoke | orphaned processes, complexity Confidence: high Scope-risk: narrow
1 parent e75e8a0 commit 8898535

File tree

8 files changed

+91
-49
lines changed

8 files changed

+91
-49
lines changed

docs/commands.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -474,22 +474,23 @@ Start local development server with hot-reload.
474474
agentcore dev
475475
agentcore dev --agent MyAgent --port 3000
476476
agentcore dev --logs # Non-interactive
477-
agentcore dev --invoke "Hello" --stream # Direct invoke
477+
agentcore dev "Hello" --stream # Invoke running dev server
478+
agentcore dev "Hello" --agent MyAgent # Invoke specific agent
478479

479480
# MCP protocol dev commands
480-
agentcore dev --invoke list-tools
481-
agentcore dev --invoke call-tool --tool myTool --input '{"arg": "value"}'
481+
agentcore dev list-tools
482+
agentcore dev call-tool --tool myTool --input '{"arg": "value"}'
482483
```
483484

484-
| Flag | Description |
485-
| ----------------------- | ------------------------------------------------------ |
486-
| `-p, --port <port>` | Port (default: 8080; MCP uses 8000, A2A uses 9000) |
487-
| `-a, --agent <name>` | Agent to run (required if multiple agents) |
488-
| `-i, --invoke <prompt>` | Invoke running server |
489-
| `-s, --stream` | Stream response (with --invoke) |
490-
| `-l, --logs` | Non-interactive stdout logging |
491-
| `--tool <name>` | MCP tool name (with `--invoke call-tool`) |
492-
| `--input <json>` | MCP tool arguments as JSON (with `--invoke call-tool`) |
485+
| Flag / Argument | Description |
486+
| -------------------- | ---------------------------------------------------- |
487+
| `[prompt]` | Send a prompt to a running dev server |
488+
| `-p, --port <port>` | Port (default: 8080; MCP uses 8000, A2A uses 9000) |
489+
| `-a, --agent <name>` | Agent to run or invoke (required if multiple agents) |
490+
| `-s, --stream` | Stream response when invoking |
491+
| `-l, --logs` | Non-interactive stdout logging |
492+
| `--tool <name>` | MCP tool name (with `call-tool` prompt) |
493+
| `--input <json>` | MCP tool arguments as JSON (with `--tool`) |
493494

494495
### invoke
495496

docs/local-development.md

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,16 @@ agentcore dev --logs
2020

2121
## Invoking Local Agents
2222

23-
With the dev server running, open another terminal:
23+
Start the dev server in one terminal, then send prompts from another:
2424

2525
```bash
26-
# Interactive chat
27-
agentcore invoke
28-
29-
# Single prompt
30-
agentcore invoke "What can you do?"
31-
32-
# With streaming
33-
agentcore invoke "Tell me a story" --stream
26+
# Terminal 1: start the dev server
27+
agentcore dev --logs
3428

35-
# Direct invoke to running server
36-
agentcore dev --invoke "Hello" --stream
29+
# Terminal 2: send prompts
30+
agentcore dev "What can you do?"
31+
agentcore dev "Tell me a story" --stream
32+
agentcore dev "Hello" --agent MyAgent
3733
```
3834

3935
## Environment Setup

src/cli/commands/dev/__tests__/dev.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ describe('dev command', () => {
77
const result = await runCLI(['dev', '--help'], process.cwd());
88

99
expect(result.exitCode).toBe(0);
10+
expect(result.stdout.includes('[prompt]'), 'Should show [prompt] positional argument').toBeTruthy();
1011
expect(result.stdout.includes('--port'), 'Should show --port option').toBeTruthy();
1112
expect(result.stdout.includes('--agent'), 'Should show --agent option').toBeTruthy();
12-
expect(result.stdout.includes('--invoke'), 'Should show --invoke option').toBeTruthy();
1313
expect(result.stdout.includes('--stream'), 'Should show --stream option').toBeTruthy();
1414
expect(result.stdout.includes('--logs'), 'Should show --logs option').toBeTruthy();
1515
expect(result.stdout.includes('8080'), 'Should show default port').toBeTruthy();
1616
});
17+
18+
it('does not show --invoke flag', async () => {
19+
const result = await runCLI(['dev', '--help'], process.cwd());
20+
21+
expect(result.exitCode).toBe(0);
22+
expect(result.stdout.includes('--invoke'), 'Should not show removed --invoke option').toBeFalsy();
23+
});
1724
});
1825

1926
describe('requires project context', () => {
@@ -28,6 +35,33 @@ describe('dev command', () => {
2835
});
2936
});
3037

38+
describe('positional prompt invoke', () => {
39+
it('exits with helpful error when no server running and no project found', async () => {
40+
// With no dev server running, invoke path shows connection error
41+
const result = await runCLI(['dev', 'Hello agent'], process.cwd());
42+
43+
expect(result.exitCode).toBe(1);
44+
const output = result.stderr.toLowerCase();
45+
expect(
46+
output.includes('dev server not running'),
47+
`Should mention dev server not running, got: ${result.stderr}`
48+
).toBeTruthy();
49+
});
50+
51+
it('does not go through requireProject guard when invoking', async () => {
52+
// Invoke path uses loadProjectConfig (soft check), not requireProject()
53+
// The error should mention the dev server, not the generic project guard message
54+
const result = await runCLI(['dev', 'test prompt'], process.cwd());
55+
56+
expect(result.exitCode).toBe(1);
57+
const output = result.stderr.toLowerCase();
58+
expect(
59+
!output.includes('run agentcore create'),
60+
`Should not show the requireProject guard message, got: ${result.stderr}`
61+
).toBeTruthy();
62+
});
63+
});
64+
3165
describe('flag validation', () => {
3266
it('rejects invalid port number', async () => {
3367
const result = await runCLI(['dev', '--port', 'abc'], process.cwd());
@@ -46,7 +80,6 @@ describe('dev command', () => {
4680

4781
expect(result.exitCode).toBe(0);
4882
expect(result.stdout.includes('--stream'), 'Should show --stream option').toBeTruthy();
49-
expect(result.stdout.includes('--invoke'), 'Should show --invoke option').toBeTruthy();
5083
});
5184
});
5285
});

src/cli/commands/dev/command.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ async function invokeDevServer(
4848
console.log(response);
4949
}
5050
} catch (err) {
51-
if (err instanceof Error && err.message.includes('ECONNREFUSED')) {
51+
if (isConnectionRefused(err)) {
5252
console.error(`Error: Dev server not running on port ${port}`);
53-
console.error('Start it with: agentcore dev');
53+
console.error('Start it with: agentcore dev --logs');
5454
} else {
5555
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
5656
}
@@ -65,16 +65,24 @@ async function invokeA2ADevServer(port: number, prompt: string, headers?: Record
6565
}
6666
process.stdout.write('\n');
6767
} catch (err) {
68-
if (err instanceof Error && err.message.includes('ECONNREFUSED')) {
68+
if (isConnectionRefused(err)) {
6969
console.error(`Error: Dev server not running on port ${port}`);
70-
console.error('Start it with: agentcore dev');
70+
console.error('Start it with: agentcore dev --logs');
7171
} else {
7272
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
7373
}
7474
process.exit(1);
7575
}
7676
}
7777

78+
function isConnectionRefused(err: unknown): boolean {
79+
if (!(err instanceof Error)) return false;
80+
// ConnectionError from invoke.ts wraps fetch failures after retries
81+
if (err.name === 'ConnectionError') return true;
82+
const msg = err.message + (err.cause instanceof Error ? err.cause.message : '');
83+
return msg.includes('ECONNREFUSED') || msg.includes('fetch failed');
84+
}
85+
7886
async function handleMcpInvoke(
7987
port: number,
8088
invokeValue: string,
@@ -96,8 +104,8 @@ async function handleMcpInvoke(
96104
}
97105
} else if (invokeValue === 'call-tool') {
98106
if (!toolName) {
99-
console.error('Error: --tool is required with --invoke call-tool');
100-
console.error('Usage: agentcore dev --invoke call-tool --tool <name> --input \'{"arg": "value"}\'');
107+
console.error('Error: --tool is required with call-tool');
108+
console.error('Usage: agentcore dev call-tool --tool <name> --input \'{"arg": "value"}\'');
101109
process.exit(1);
102110
}
103111
// Initialize session first, then call tool with the session ID
@@ -117,14 +125,14 @@ async function handleMcpInvoke(
117125
} else {
118126
console.error(`Error: Unknown MCP invoke command "${invokeValue}"`);
119127
console.error('Usage:');
120-
console.error(' agentcore dev --invoke list-tools');
121-
console.error(' agentcore dev --invoke call-tool --tool <name> --input \'{"arg": "value"}\'');
128+
console.error(' agentcore dev list-tools');
129+
console.error(' agentcore dev call-tool --tool <name> --input \'{"arg": "value"}\'');
122130
process.exit(1);
123131
}
124132
} catch (err) {
125-
if (err instanceof Error && err.message.includes('ECONNREFUSED')) {
133+
if (isConnectionRefused(err)) {
126134
console.error(`Error: Dev server not running on port ${port}`);
127-
console.error('Start it with: agentcore dev');
135+
console.error('Start it with: agentcore dev --logs');
128136
} else {
129137
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
130138
}
@@ -137,20 +145,20 @@ export const registerDev = (program: Command) => {
137145
.command('dev')
138146
.alias('d')
139147
.description(COMMAND_DESCRIPTIONS.dev)
148+
.argument('[prompt]', 'Send a prompt to a running dev server [non-interactive]')
140149
.option('-p, --port <port>', 'Port for development server', '8080')
141150
.option('-a, --agent <name>', 'Agent to run or invoke (required if multiple agents)')
142-
.option('-i, --invoke <prompt>', 'Invoke running dev server (use --agent if multiple) [non-interactive]')
143-
.option('-s, --stream', 'Stream response when using --invoke [non-interactive]')
151+
.option('-s, --stream', 'Stream response when invoking [non-interactive]')
144152
.option('-l, --logs', 'Run dev server with logs to stdout [non-interactive]')
145-
.option('--tool <name>', 'MCP tool name (used with --invoke call-tool) [non-interactive]')
146-
.option('--input <json>', 'MCP tool arguments as JSON (used with --invoke call-tool) [non-interactive]')
153+
.option('--tool <name>', 'MCP tool name (used with "call-tool" prompt) [non-interactive]')
154+
.option('--input <json>', 'MCP tool arguments as JSON (used with --tool) [non-interactive]')
147155
.option(
148156
'-H, --header <header>',
149157
'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]',
150158
(val: string, prev: string[]) => [...prev, val],
151159
[] as string[]
152160
)
153-
.action(async opts => {
161+
.action(async (positionalPrompt: string | undefined, opts) => {
154162
try {
155163
const port = parseInt(opts.port, 10);
156164

@@ -160,9 +168,11 @@ export const registerDev = (program: Command) => {
160168
headers = parseHeaderFlags(opts.header);
161169
}
162170

163-
// If --invoke provided, call the dev server and exit
164-
if (opts.invoke) {
165-
const invokeProject = await loadProjectConfig(getWorkingDirectory());
171+
// If a prompt is provided, invoke a running dev server
172+
const invokePrompt = positionalPrompt;
173+
if (invokePrompt !== undefined) {
174+
const workingDir = getWorkingDirectory();
175+
const invokeProject = await loadProjectConfig(workingDir);
166176

167177
// Determine which agent/port to invoke
168178
let invokePort = port;
@@ -185,11 +195,11 @@ export const registerDev = (program: Command) => {
185195

186196
// Protocol-aware dispatch
187197
if (protocol === 'MCP') {
188-
await handleMcpInvoke(invokePort, opts.invoke, opts.tool, opts.input, headers);
198+
await handleMcpInvoke(invokePort, invokePrompt, opts.tool, opts.input, headers);
189199
} else if (protocol === 'A2A') {
190-
await invokeA2ADevServer(invokePort, opts.invoke, headers);
200+
await invokeA2ADevServer(invokePort, invokePrompt, headers);
191201
} else {
192-
await invokeDevServer(invokePort, opts.invoke, opts.stream ?? false, headers);
202+
await invokeDevServer(invokePort, invokePrompt, opts.stream ?? false, headers);
193203
}
194204
return;
195205
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ export abstract class DevServer {
8282
kill(): void {
8383
if (!this.child || this.child.killed) return;
8484
this.child.kill('SIGTERM');
85-
setTimeout(() => {
85+
const killTimer = setTimeout(() => {
8686
if (this.child && !this.child.killed) this.child.kill('SIGKILL');
8787
}, 2000);
88+
killTimer.unref();
8889
}
8990

9091
/** Mode-specific setup (e.g., venv creation, container image build). Returns false to abort. */

src/cli/operations/dev/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
findAvailablePort,
33
waitForPort,
4+
waitForServerReady,
45
createDevServer,
56
DevServer,
67
type LogLevel,

src/cli/operations/dev/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { DevServer, DevServerOptions } from './dev-server';
77
* Dev server barrel module.
88
* Re-exports types, utilities, and the factory function.
99
*/
10-
export { findAvailablePort, waitForPort } from './utils';
10+
export { findAvailablePort, waitForPort, waitForServerReady } from './utils';
1111
export { DevServer, type LogLevel, type DevServerCallbacks, type DevServerOptions } from './dev-server';
1212
export { CodeZipDevServer } from './codezip-dev-server';
1313
export { ContainerDevServer } from './container-dev-server';

src/cli/tui/copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const COMMAND_DESCRIPTIONS = {
3434
add: 'Add resources (agent, evaluator, online-eval, memory, credential, target)',
3535
create: 'Create a new AgentCore project',
3636
deploy: 'Deploy project infrastructure to AWS via CDK.',
37-
dev: 'Launch local development server with hot-reload.',
37+
dev: 'Launch local dev server, or invoke an agent locally.',
3838
invoke: 'Invoke a deployed agent endpoint.',
3939
logs: 'Stream or search agent runtime logs.',
4040
package: 'Package agent artifacts without deploying.',

0 commit comments

Comments
 (0)