Skip to content

Commit cf19e57

Browse files
authored
feat(runtime): add interactive runtime shell support (#505)
* feat(runtime): add interactive shell support for InvokeAgentRuntimeCommandShell (#133) * feat(runtime): add interactive terminal SDK for InvokeAgentRuntimeCommandWithWebSocketStream Adds SDK support for the AgentCore Runtime interactive terminal API, which streams a real shell session over WebSocket using a PTY (pseudo-terminal) running inside sandbox-agent on the customer VM. * fix(runtime): address functional test findings for interactive terminal SDK - Extract parse_runtime_arn and validate_command_session_id as module-level functions; remove private instance methods _parse_runtime_arn and _validate_command_session_id - Add region mismatch check in open_terminal() and TerminalSession.__init__ so a client/ARN region mismatch raises ValueError immediately instead of producing a confusing 404 at connect time - Reject non-str command_session_id at construction (isinstance guard before regex match) - Use \Z anchor in _COMMAND_SESSION_ID_RE to reject trailing newline - Add eager validation in open_terminal() and TerminalSession.__init__ - Log InvalidStatus response body on WebSocket upgrade failure (HTTP 4xx/5xx) - Short-circuit _reconnect_with_backoff when reconnect_window <= 0 - Fix reconnected/kicked flags reset on each _connect() call - Change raise StopAsyncIteration inside except clauses to raise ... from None (B904) and wrap long logger calls (E501) throughout terminal.py - Add r-prefix to docstrings containing backslashes (D301) in command_stream.py and terminal.py - Pin griffe<2 and fix ExplanationStyle.MARKDOWN casing in breaking-change workflow (griffe 2.0 removed the lowercase enum alias) - Add 38 new unit tests covering the above; 188 unit tests pass * refactor(runtime): reorganize shell feature into runtime/shell/ subpackage Rename all terminal/command-stream symbols to shell/shell_id to match the renamed service API (InvokeAgentRuntimeCommandShell). Reorganize the flat shell.py and shell_protocol.py into a structured subpackage with separate modules for auth, config, protocol, session, and validation. Wire-level parameter commandSessionId is intentionally unchanged — the service API has not yet been updated. * fix(runtime/shell): address code review findings - Raise StopAsyncIteration from None consistently across all 8 sites - Reset bytes_dropped to 0 in _connect() to reflect most recent disconnect - Guard bearer token length before embedding in Sec-WebSocket-Protocol (max 4096 chars encoded) - Expose session_id as a public attribute alongside shell_id - Log WARNING (not silent stop) when server sends 1001 Going Away without reconnect_config - Reject float width/height in encode_resize() with a clear TypeError - Remove duplicate validate_shell_id() call in open_shell() (ShellSession.__init__ already validates) * chore(runtime/shell): apply ruff lint and format fixes Fix import sort order and line length in runtime/__init__.py; reformat shell subpackage files to satisfy ruff-format. * chore: apply ruff lint and format fixes across src/ and tests/ Fix import sort, line length, and formatting across all Python files to satisfy pre-commit ruff checks in CI. * fix(runtime/shell): address PR review findings - Filter HEARTBEAT frames in __anext__ (server echoes 0x05 back; wire-level keepalive must not be surfaced to caller iterators) - Remove bytes_dropped reset in _connect(): server reports a cumulative monotonic count, so resetting creates a false zero window before the post-drain confirmation arrives - Fix test_pending_frames_cleared_on_reconnect: previous assertion accepted ["stale","fresh"] whether or not clearing existed; reworked to directly verify _pending_frames is empty after _connect() is called on reconnect * feat(runtime/shell): expose exit_code on ShellSession Adds `ShellSession.exit_code: Optional[int]` so callers can check the shell process exit status after the async-for loop drains. - `None` until the termination STATUS frame arrives - `0` for a clean exit, non-zero for failed commands or signal-killed processes (e.g. 137 for SIGKILL) - New `_parse_exit_code` static helper extracts the code from the metav1.Status JSON causes list, falling back to 0 for platform errors that lack an ExitCode cause - Both termination paths (pending-frames drain and main recv loop) set the attribute before marking `_closed = True` * test(runtime/shell): add unit tests for ShellSession.exit_code Five tests in TestShellSessionExitCode: - clean exit (status=Success) → exit_code 0 - non-zero exit code extracted from ExitCode cause - exit_code is None during STDOUT frames, set only on termination STATUS - exit_code set via pending-frames drain path (stashed STDOUT + exit STATUS) - platform InternalError without ExitCode cause falls back to 0 * fix(runtime/shell): return None from _parse_exit_code when no ExitCode cause present Returning 0 as the fallback was ambiguous — callers could not distinguish a clean shell exit from a platform InternalError that carried no exit code. Changed _parse_exit_code return type to Optional[int] and the fallback from 0 to None. Updated docstring and the corresponding unit test. * fix(runtime/shell): auto-generate session_id in ShellSession.__init__ (#135) * fix(runtime/shell): auto-generate session_id in ShellSession.__init__ Previously self._session_id stored None when the caller omitted session_id. Each _connect() call then passed None to connect_shell(), which generated a fresh UUID each time — routing to a different VM on every reconnect, so shell_id was never found and reconnected was always False. Fix mirrors the existing shell_id pattern: generate once in __init__ and reuse the same value across all _connect() calls. Also tightens the public attribute type from Optional[str] to str since session_id is now always set. Drop _shell_id/_session_id backing fields. Both public attributes (shell_id, session_id) are the single source of truth. Also reads X-Amzn-Bedrock-AgentCore-Runtime-Session-Id from the 101 response to surface the DP-confirmed session ID, same as shell_id already does via X-Command-Session-Id. * test(runtime/shell): add unit tests for session_id fixes - Fix _make_ws() to set ws.response.headers={} — prevents AutoMock.get() returning a truthy coroutine that silently overwrites shell_id/session_id in every existing test (also dropped 67 spurious coroutine warnings) - Add test_session_id_stable_across_two_connect_calls: verifies session_id is unchanged across reconnects so all _connect() calls route to same VM - Add test_connect_reads_session_id_from_101_header: verifies X-Amzn-Bedrock-AgentCore-Runtime-Session-Id updates session_id - Add test_connect_timeout_proceeds_with_warning headers stub - Add TestShellSessionInit tests: None auto-generates UUID, "" auto-generates UUID, explicit value is preserved * fix(runtime/shell): update wire names for InvokeAgentRuntimeCommandShell API (#136) * fix(runtime/shell): update wire names for InvokeAgentRuntimeCommandShell API Path: /ws/commands → /ws/shells Query param: commandSessionId → shellId 101 header: X-Command-Session-Id → X-Amzn-Bedrock-AgentCore-Shell-Id STATUS frame wire protocol: commandSessionId → shellId * fix(runtime/shell): update remaining ws/commands references to ws/shells Update stale /ws/commands references in docstring and test fixtures that were missed in the prior wire rename commit. * docs(runtime/shell): clarify that both shell_id and session_id are required for reconnect (#139) The previous docstrings only mentioned shell_id in reconnect examples, omitting session_id. Without a pinned session_id the SDK auto-generates a fresh UUID on each open_shell call, routing to a different VM where the PTY no longer exists (reconnected=False). * chore(runtime/shell): fix ruff import order in session.py Move `from ..models` import above relative imports to satisfy isort. * revert(ci): restore griffe==1.7.3 pin and b.explain() in breaking-change workflow The griffe change was bundled into the shell feature cherry-pick but belongs to the private repo only. Restore the public repo's original pinned version and call style.
1 parent 69954eb commit cf19e57

15 files changed

Lines changed: 3288 additions & 170 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ dependencies = [
3333
"starlette>=0.46.2",
3434
"typing-extensions>=4.13.2,<5.0.0",
3535
"uvicorn>=0.34.2",
36-
"websockets>=12.0",
36+
"websockets>=13.0",
3737
]
3838

3939
[project.scripts]

src/bedrock_agentcore/runtime/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,33 @@
1010
from .app import BedrockAgentCoreApp
1111
from .context import BedrockAgentCoreContext, RequestContext
1212
from .models import PingStatus
13+
from .shell import (
14+
AuthMode,
15+
OAuthAuth,
16+
PresignedAuth,
17+
ReconnectConfig,
18+
ShellChannel,
19+
ShellFrame,
20+
ShellFramer,
21+
ShellSession,
22+
)
1323

1424
__all__ = [
1525
"AgentCoreRuntimeClient",
1626
"AGUIApp",
27+
"AuthMode",
1728
"BedrockAgentCoreApp",
1829
"BedrockCallContextBuilder",
19-
"RequestContext",
2030
"BedrockAgentCoreContext",
31+
"OAuthAuth",
32+
"PresignedAuth",
33+
"ReconnectConfig",
34+
"RequestContext",
2135
"PingStatus",
36+
"ShellChannel",
37+
"ShellFrame",
38+
"ShellFramer",
39+
"ShellSession",
2240
"build_a2a_app",
2341
"build_ag_ui_app",
2442
"build_runtime_url",

0 commit comments

Comments
 (0)