gentui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gentui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Gentui: a terminal client for any AG-UI agent backend."""
gentui/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """`gentui` command: point it at any AG-UI backend and go."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from gentui.config import load_config
7
+
8
+
9
+ def _version() -> str:
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ return version("gentui")
14
+ except PackageNotFoundError: # running from a source tree that is not installed
15
+ return "unknown"
16
+
17
+
18
+ def parse_headers(items: list[str] | None) -> dict[str, str] | None:
19
+ if not items:
20
+ return None
21
+ headers = {}
22
+ for item in items:
23
+ key, sep, value = item.partition(":")
24
+ if not sep:
25
+ raise SystemExit(f"--header expects 'Name: value', got {item!r}")
26
+ headers[key.strip()] = value.strip()
27
+ return headers
28
+
29
+
30
+ def main(argv: list[str] | None = None) -> None:
31
+ parser = argparse.ArgumentParser(
32
+ prog="gentui",
33
+ description="Terminal client for any AG-UI agent backend.",
34
+ epilog="Options can also live in gentui.toml (see gentui.example.toml).",
35
+ )
36
+ parser.add_argument(
37
+ "url_pos", nargs="?", metavar="URL|ARN",
38
+ help="AG-UI endpoint, or an AgentCore runtime ARN (same as --url / --agentcore-arn)",
39
+ )
40
+ parser.add_argument("--url", help="AG-UI endpoint [default: http://localhost:8000/agent]")
41
+ parser.add_argument("--agentcore-arn", metavar="ARN", help="agent on Amazon Bedrock AgentCore Runtime (AG-UI protocol)")
42
+ parser.add_argument("--region", help="AWS region for AgentCore [default: the region in the ARN]")
43
+ parser.add_argument("--profile", help="AWS profile for AgentCore [default: the standard credential chain]")
44
+ parser.add_argument("--qualifier", help="AgentCore runtime endpoint name [default: DEFAULT]")
45
+ parser.add_argument("--token", help="send 'Authorization: Bearer <token>' (or set GENTUI_TOKEN)")
46
+ parser.add_argument("--header", "-H", action="append", metavar="'Name: value'", help="extra HTTP header (repeatable)")
47
+ parser.add_argument("--config", "-c", metavar="FILE", help="config file [default: ./gentui.toml]")
48
+ parser.add_argument("--theme", help="Textual theme name")
49
+ parser.add_argument("--css", metavar="FILE", help="your own CSS file (hot-reloaded)")
50
+ parser.add_argument("--plugin", "-p", action="append", metavar="MODULE|FILE", help="load a plugin (repeatable)")
51
+ parser.add_argument("--version", action="version", version=f"gentui {_version()}")
52
+ parser.add_argument("--no-reasoning", action="store_true", help="hide the model's chain of thought")
53
+ parser.add_argument("--dev", action="store_true", help="open the AG-UI event inspector at start")
54
+ args = parser.parse_args(argv)
55
+
56
+ target = args.url or args.url_pos
57
+ arn = args.agentcore_arn or (target if target and target.startswith("arn:") else None)
58
+ try:
59
+ config = load_config(
60
+ args.config,
61
+ url=None if arn else target,
62
+ agentcore_arn=arn,
63
+ region=args.region,
64
+ aws_profile=args.profile,
65
+ qualifier=args.qualifier,
66
+ token=args.token,
67
+ headers=parse_headers(args.header),
68
+ theme=args.theme,
69
+ css=args.css,
70
+ plugins=args.plugin,
71
+ show_reasoning=False if args.no_reasoning else None,
72
+ dev_pane=True if args.dev else None,
73
+ )
74
+ except (OSError, ValueError) as exc:
75
+ sys.exit(f"gentui: {exc}")
76
+
77
+ from gentui.tui.agui_client import AguiClient, BackendError
78
+ from gentui.tui.app import GentuiApp
79
+
80
+ if config.agentcore_arn:
81
+ try:
82
+ from gentui.tui.agentcore_client import AgentCoreClient
83
+
84
+ client = AgentCoreClient(
85
+ config.agentcore_arn, config.region, config.aws_profile, config.qualifier,
86
+ config.timeout, config.send_history,
87
+ )
88
+ except ImportError:
89
+ sys.exit(
90
+ "gentui: AgentCore support needs boto3. Install it with:\n"
91
+ " uv sync --extra agentcore (from a clone)\n"
92
+ " pip install 'gentui[agentcore]' (from PyPI, once published)"
93
+ )
94
+ except (BackendError, ValueError) as exc:
95
+ sys.exit(f"gentui: {exc}")
96
+ else:
97
+ client = AguiClient(config.url, config.request_headers, config.timeout, config.send_history)
98
+ GentuiApp(client, config).run()
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
gentui/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """User configuration: where the backend is and how the UI looks.
2
+
3
+ Sources, lowest to highest priority: defaults < config file < environment < CLI flags.
4
+
5
+ Config file (first found wins): --config PATH, ./gentui.toml, ~/.config/gentui/config.toml
6
+ See `gentui.example.toml` for every option.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import tomllib
12
+ from dataclasses import dataclass, field, fields
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ DEFAULT_WELCOME = (
17
+ "◈ Welcome to Gentui!\n\n"
18
+ " /help for commands · ctrl+q to quit · d for the dev pane\n"
19
+ " backend: {url}\n"
20
+ " cwd: {cwd}"
21
+ )
22
+
23
+
24
+ _RUNTIME_ARN = re.compile(
25
+ r"^(?P<runtime>arn:aws[a-z-]*:bedrock-agentcore:(?P<region>[a-z0-9-]+):(?P<account>\d{12}):runtime/[^/\s]+)"
26
+ r"(?:/runtime-endpoint/(?P<endpoint>[^/\s]+))?$"
27
+ )
28
+
29
+
30
+ def _match_runtime_arn(arn: str) -> re.Match[str]:
31
+ match = _RUNTIME_ARN.match(arn.strip())
32
+ if not match:
33
+ raise ValueError(
34
+ f"not an AgentCore runtime ARN: {arn!r} "
35
+ "(expected arn:aws:bedrock-agentcore:<region>:<account>:runtime/<name>"
36
+ "[/runtime-endpoint/<endpoint>])"
37
+ )
38
+ return match
39
+
40
+
41
+ def parse_runtime_arn(arn: str) -> tuple[str, str]:
42
+ """(region, account id) of an AgentCore runtime ARN. Raises ValueError for anything else."""
43
+ match = _match_runtime_arn(arn)
44
+ return match["region"], match["account"]
45
+
46
+
47
+ def split_runtime_arn(arn: str) -> tuple[str, str | None]:
48
+ """(runtime ARN, endpoint name). An endpoint ARN (`.../runtime/<name>/runtime-endpoint/<endpoint>`) is
49
+ accepted: invoke_agent_runtime wants the plain runtime ARN, with the endpoint passed as its qualifier."""
50
+ match = _match_runtime_arn(arn)
51
+ return match["runtime"], match["endpoint"]
52
+
53
+
54
+ @dataclass
55
+ class Config:
56
+ # -- backend -----------------------------------------------------------------------
57
+ url: str = "http://localhost:8000/agent" # any AG-UI endpoint
58
+ # An agent hosted on Amazon Bedrock AgentCore Runtime (AG-UI protocol), invoked with boto3.
59
+ # When set it is used instead of `url`. Needs the optional dependency: gentui[agentcore].
60
+ agentcore_arn: str | None = None
61
+ region: str | None = None # default: the region in the ARN
62
+ aws_profile: str | None = None # default: the standard AWS credential chain
63
+ qualifier: str | None = None # runtime endpoint name; default: the runtime's DEFAULT endpoint
64
+ headers: dict[str, str] = field(default_factory=dict) # extra HTTP headers (auth etc.)
65
+ token: str | None = None # shortcut for "Authorization: Bearer <token>"
66
+ forwarded_props: dict[str, Any] = field(default_factory=dict) # sent with every run
67
+ send_history: bool = False # True: send the whole conversation each run (stateless backends need it)
68
+ timeout: float | None = None # seconds between bytes; None = wait forever (agents are slow)
69
+
70
+ # -- look & feel -------------------------------------------------------------------
71
+ title: str = "Gentui"
72
+ subtitle: str = "Generative UI for your terminal"
73
+ welcome: str = DEFAULT_WELCOME
74
+ placeholder: str = "Ask anything…"
75
+ theme: str = "gentui" # "gentui", "claude" or any Textual theme name; /theme lists them
76
+ splash: bool = True # animated logo on startup (any key skips it)
77
+ logo: bool = True # keep the finished logo at the top of the chat after the splash
78
+ show_time: bool = True # a timestamp on every message
79
+ time_format: str = "%H:%M" # strftime format for those timestamps
80
+ css: str | None = None # your own Textual CSS file, hot-reloaded while the app runs
81
+ show_reasoning: bool = True # render the model's chain of thought
82
+ dev_pane: bool = False # start with the AG-UI event inspector open
83
+
84
+ # -- extending ---------------------------------------------------------------------
85
+ plugins: list[str] = field(default_factory=list) # module names or .py paths to load
86
+ widgets: dict[str, str] = field(default_factory=dict) # tool name -> "module:Class"
87
+ default_widget: str | None = None # "module:Class" for tools without a widget
88
+
89
+ source: Path | None = None # the config file that was loaded (informational)
90
+
91
+ @property
92
+ def request_headers(self) -> dict[str, str]:
93
+ headers = dict(self.headers)
94
+ if self.token:
95
+ headers.setdefault("Authorization", f"Bearer {self.token}")
96
+ return headers
97
+
98
+ @property
99
+ def target(self) -> str:
100
+ """What the client talks to: the AgentCore runtime ARN, or the URL."""
101
+ return self.agentcore_arn or self.url
102
+
103
+ @property
104
+ def welcome_text(self) -> str:
105
+ return self.welcome.replace("{url}", self.target).replace("{cwd}", os.getcwd())
106
+
107
+
108
+ def find_config(explicit: str | None = None) -> Path | None:
109
+ if explicit:
110
+ path = Path(explicit).expanduser()
111
+ if not path.is_file():
112
+ raise FileNotFoundError(f"config file not found: {path}")
113
+ return path
114
+ for path in (Path("gentui.toml"), Path.home() / ".config" / "gentui" / "config.toml"):
115
+ if path.is_file():
116
+ return path
117
+ return None
118
+
119
+
120
+ def load_config(path: str | None = None, **overrides: Any) -> Config:
121
+ """Build a Config. `overrides` (e.g. from CLI flags) win; None values are ignored."""
122
+ cfg = Config()
123
+ known = {f.name for f in fields(Config)} - {"source"}
124
+
125
+ file = find_config(path)
126
+ if file:
127
+ data = tomllib.loads(file.read_text(encoding="utf-8"))
128
+ unknown = set(data) - known
129
+ if unknown:
130
+ raise ValueError(f"{file}: unknown option(s): {', '.join(sorted(unknown))}")
131
+ for key, value in data.items():
132
+ setattr(cfg, key, value)
133
+ if cfg.css: # relative CSS paths are relative to the config file
134
+ cfg.css = str((file.parent / cfg.css).expanduser())
135
+ cfg.source = file
136
+
137
+ cfg.url = os.environ.get("GENTUI_URL", cfg.url)
138
+ cfg.token = os.environ.get("GENTUI_TOKEN", cfg.token)
139
+ cfg.agentcore_arn = os.environ.get("GENTUI_AGENTCORE_ARN", cfg.agentcore_arn)
140
+
141
+ for key, value in overrides.items():
142
+ if value is not None:
143
+ if key not in known:
144
+ raise KeyError(key)
145
+ setattr(cfg, key, value)
146
+ if cfg.agentcore_arn:
147
+ parse_runtime_arn(cfg.agentcore_arn) # fail early with a clear message
148
+ return cfg
gentui/plugins.py ADDED
@@ -0,0 +1,136 @@
1
+ """Plugin API. A plugin is a plain Python file or module that imports these decorators.
2
+
3
+ from gentui.plugins import register_command, on_event
4
+ from gentui.tui.widgets.base import ToolWidget
5
+ from gentui.tui.widgets.registry import register_widget
6
+
7
+ @register_widget("weather") # render tool calls named "weather"
8
+ class WeatherWidget(ToolWidget): ...
9
+
10
+ @register_command("ping", "say pong") # adds /ping
11
+ def ping(app, args): app.notify("pong")
12
+
13
+ @on_event("TOOL_CALL_RESULT") # runs for every matching AG-UI event
14
+ def log_results(app, event): ...
15
+
16
+ def setup(app): ... # optional; called once the app is mounted
17
+
18
+ Where plugins are loaded from: the `plugins = [...]` config option / `--plugin`,
19
+ `./gentui_plugins/*.py`, `~/.config/gentui/plugins/*.py`, and the `gentui.plugins`
20
+ entry-point group (for pip-installable plugins).
21
+ """
22
+
23
+ import importlib
24
+ import importlib.metadata
25
+ import importlib.util
26
+ import inspect
27
+ import sys
28
+ from collections.abc import Callable
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ Command = Callable[[Any, str], Any] # (app, argument string)
33
+ Hook = Callable[[Any, Any], Any] # (app, ag_ui event)
34
+
35
+ COMMANDS: dict[str, tuple[Command, str]] = {}
36
+ HOOKS: list[tuple[frozenset[str] | None, Hook]] = []
37
+ SETUPS: list[Callable[[Any], Any]] = []
38
+
39
+
40
+ def register_command(name: str, help: str = "") -> Callable[[Command], Command]:
41
+ def deco(fn: Command) -> Command:
42
+ COMMANDS[name.lstrip("/")] = (fn, help)
43
+ return fn
44
+
45
+ return deco
46
+
47
+
48
+ def on_event(*types: str) -> Callable[[Hook], Hook]:
49
+ """Hook called with (app, event). No types = every event. Types are EventType names."""
50
+
51
+ def deco(fn: Hook) -> Hook:
52
+ HOOKS.append((frozenset(types) or None, fn))
53
+ return fn
54
+
55
+ return deco
56
+
57
+
58
+ async def call(fn: Callable[..., Any], *args: Any) -> Any:
59
+ result = fn(*args)
60
+ return await result if inspect.isawaitable(result) else result
61
+
62
+
63
+ async def dispatch_event(app: Any, event: Any) -> None:
64
+ kind = event.type.value
65
+ for types, hook in HOOKS:
66
+ if types is None or kind in types:
67
+ try:
68
+ await call(hook, app, event)
69
+ except Exception as exc: # noqa: BLE001 - a broken plugin must not kill the UI
70
+ app.notify(f"plugin hook {hook.__name__} failed: {exc!r}", severity="error")
71
+
72
+
73
+ def import_string(spec: str) -> Any:
74
+ """'package.module:Name' -> the object."""
75
+ module, _, name = spec.partition(":")
76
+ if not name:
77
+ raise ValueError(f"expected 'module:Name', got {spec!r}")
78
+ return getattr(importlib.import_module(module), name)
79
+
80
+
81
+ def _load_file(path: Path) -> None:
82
+ name = f"gentui_plugin_{path.stem}"
83
+ if name in sys.modules: # already loaded (e.g. several apps in one process)
84
+ return
85
+ spec = importlib.util.spec_from_file_location(name, path)
86
+ if spec is None or spec.loader is None:
87
+ raise ImportError(f"cannot load plugin {path}")
88
+ module = importlib.util.module_from_spec(spec)
89
+ sys.modules[name] = module
90
+ spec.loader.exec_module(module)
91
+ _register_setup(module)
92
+
93
+
94
+ def _register_setup(module: Any) -> None:
95
+ setup = getattr(module, "setup", None)
96
+ if callable(setup) and setup not in SETUPS:
97
+ SETUPS.append(setup)
98
+
99
+
100
+ def load_plugin(spec: str) -> None:
101
+ """Load one plugin: a .py file, a directory of .py files, or an importable module."""
102
+ path = Path(spec).expanduser()
103
+ if path.is_dir():
104
+ for file in sorted(path.glob("*.py")):
105
+ if not file.name.startswith("_"):
106
+ _load_file(file)
107
+ elif path.suffix == ".py":
108
+ _load_file(path)
109
+ else:
110
+ _register_setup(importlib.import_module(spec))
111
+
112
+
113
+ def load_all(extra: list[str], widgets: dict[str, str], default_widget: str | None) -> list[str]:
114
+ """Load every plugin source and apply config widget overrides. Returns error messages."""
115
+ from gentui.tui.widgets.registry import register_widget, set_default_widget
116
+
117
+ errors: list[str] = []
118
+
119
+ def attempt(label: str, fn: Callable[[], None]) -> None:
120
+ try:
121
+ fn()
122
+ except Exception as exc: # noqa: BLE001 - report, keep starting
123
+ errors.append(f"{label}: {exc!r}")
124
+
125
+ for ep in importlib.metadata.entry_points(group="gentui.plugins"):
126
+ attempt(f"plugin {ep.name}", lambda ep=ep: _register_setup(ep.load())) # importing registers
127
+ for folder in (Path.home() / ".config" / "gentui" / "plugins", Path("gentui_plugins")):
128
+ if folder.is_dir():
129
+ attempt(f"plugins in {folder}", lambda folder=folder: load_plugin(str(folder)))
130
+ for spec in extra:
131
+ attempt(f"plugin {spec}", lambda spec=spec: load_plugin(spec))
132
+ for tool, spec in widgets.items():
133
+ attempt(f"widget {tool}", lambda tool=tool, spec=spec: register_widget(tool)(import_string(spec)))
134
+ if default_widget:
135
+ attempt("default_widget", lambda: set_default_widget(import_string(default_widget)))
136
+ return errors
gentui/tui/__init__.py ADDED
File without changes
@@ -0,0 +1,182 @@
1
+ """Amazon Bedrock AgentCore Runtime transport.
2
+
3
+ An agent deployed on AgentCore Runtime with the **AG-UI protocol** accepts a `RunAgentInput` on
4
+ `/invocations` and answers with an SSE stream of AG-UI events. This client sends that request with
5
+ boto3's `invoke_agent_runtime` (so AWS credentials and SigV4 signing just work) and feeds the stream
6
+ into the same event handling as the plain HTTP client.
7
+
8
+ Needs boto3: `pip install 'gentui[agentcore]'`.
9
+ """
10
+
11
+ import asyncio
12
+ import hashlib
13
+ import re
14
+ import threading
15
+ import time
16
+ from collections.abc import AsyncIterator, Iterator
17
+ from typing import Any
18
+
19
+ from gentui.config import parse_runtime_arn, split_runtime_arn
20
+ from gentui.tui.agui_client import AguiClient, BackendError
21
+
22
+ _DONE = object()
23
+ _RETRIES = 3 # for RetryableConflictException ("session busy"); AG-UI clients are expected to retry it
24
+ _SESSION_MIN, _SESSION_MAX = 33, 256 # the API's limits for runtimeSessionId
25
+
26
+
27
+ def session_id_for(thread_id: str) -> str:
28
+ """A stable AgentCore runtime session id for a Gentui conversation.
29
+
30
+ AgentCore keeps one isolated session (and its state) per id, so one thread = one session. The API
31
+ requires 33 to 256 characters, so short or odd thread ids are made safe and padded with a hash."""
32
+ clean = re.sub(r"[^A-Za-z0-9_-]", "-", thread_id)
33
+ sid = f"gentui-{clean}"
34
+ if len(sid) < _SESSION_MIN:
35
+ sid = f"{sid}-{hashlib.sha256(thread_id.encode()).hexdigest()}"
36
+ return sid[:_SESSION_MAX]
37
+
38
+
39
+ def explain(exc: BaseException) -> BackendError:
40
+ """Turn a boto3 / botocore exception into a message that tells the user what to do."""
41
+ name = type(exc).__name__
42
+ error = (getattr(exc, "response", None) or {}).get("Error", {})
43
+ code, message = error.get("Code", name), error.get("Message", str(exc))
44
+ if name in ("NoCredentialsError", "PartialCredentialsError"):
45
+ return BackendError(
46
+ "No AWS credentials found. Run `aws sso login`, or set AWS_PROFILE / "
47
+ "AWS_ACCESS_KEY_ID, or pass --profile."
48
+ )
49
+ if code in ("ExpiredTokenException", "ExpiredToken", "UnrecognizedClientException", "InvalidSignatureException"):
50
+ return BackendError(f"Your AWS credentials were rejected ({code}). Refresh them (e.g. `aws sso login`).")
51
+ friendly = {
52
+ "AccessDeniedException": "Access denied. The caller needs bedrock-agentcore:InvokeAgentRuntime on this "
53
+ "runtime (and bedrock-agentcore:InvokeAgentRuntimeForUser if a user id is used).",
54
+ "ResourceNotFoundException": "Runtime not found. Check the ARN, the region and --qualifier.",
55
+ "ThrottlingException": "AgentCore is throttling requests. Wait a moment and try again.",
56
+ "ServiceQuotaExceededException": "AgentCore service quota exceeded (too many sessions or requests).",
57
+ "RuntimeClientError": "The agent code failed while running. Check the runtime's CloudWatch logs.",
58
+ "RetryableConflictException": "The runtime session is busy (starting or finishing another call). Try again.",
59
+ }
60
+ if code in friendly:
61
+ return BackendError(f"{friendly[code]} ({code})")
62
+ if name in ("EndpointConnectionError", "ConnectTimeoutError", "ReadTimeoutError", "ConnectionClosedError"):
63
+ return BackendError(f"Cannot reach AgentCore: {exc}")
64
+ return BackendError(f"AgentCore error: {code}: {message}")
65
+
66
+
67
+ class AgentCoreClient(AguiClient):
68
+ """Same interface and behaviour as `AguiClient`, but the transport is boto3 invoke_agent_runtime."""
69
+
70
+ def __init__(
71
+ self,
72
+ arn: str,
73
+ region: str | None = None,
74
+ profile: str | None = None,
75
+ qualifier: str | None = None,
76
+ timeout: float | None = None,
77
+ send_history: bool = False,
78
+ client: Any = None,
79
+ ) -> None:
80
+ arn_region, _ = parse_runtime_arn(arn) # ValueError for a malformed ARN
81
+ runtime_arn, endpoint = split_runtime_arn(arn)
82
+ if qualifier and endpoint and qualifier != endpoint:
83
+ raise ValueError(f"the ARN names endpoint {endpoint!r} but --qualifier is {qualifier!r}; use only one")
84
+ super().__init__(runtime_arn, None, timeout, send_history)
85
+ self.arn = runtime_arn # the API wants the plain runtime ARN ...
86
+ self.qualifier = qualifier or endpoint # ... and the endpoint as `qualifier`
87
+ self._client = client if client is not None else self._make_client(region or arn_region, profile, timeout)
88
+
89
+ @staticmethod
90
+ def _make_client(region: str, profile: str | None, timeout: float | None) -> Any:
91
+ import boto3 # optional dependency: ImportError is handled by the CLI with install instructions
92
+ from botocore.config import Config
93
+ from botocore.exceptions import ProfileNotFound
94
+
95
+ try:
96
+ session = boto3.Session(profile_name=profile, region_name=region)
97
+ except ProfileNotFound as exc:
98
+ raise BackendError(f"AWS profile {profile!r} was not found.") from exc
99
+ # Agents can think for a long time without sending bytes; botocore needs a number, not None.
100
+ config = Config(read_timeout=timeout or 3600, connect_timeout=10, retries={"max_attempts": 2, "mode": "standard"})
101
+ return session.client("bedrock-agentcore", config=config)
102
+
103
+ # -- transport -------------------------------------------------------------------------------
104
+
105
+ async def _stream_lines(self, body: dict[str, Any]) -> AsyncIterator[str]:
106
+ """boto3 is blocking, so the call and the stream read run in a thread that feeds a queue."""
107
+ import json
108
+
109
+ loop = asyncio.get_running_loop()
110
+ queue: asyncio.Queue[Any] = asyncio.Queue()
111
+ stop = threading.Event()
112
+ payload = json.dumps(body).encode()
113
+ session_id = session_id_for(str(body.get("threadId", "")))
114
+
115
+ def worker() -> None:
116
+ try:
117
+ for line in self._invoke(payload, session_id, stop):
118
+ loop.call_soon_threadsafe(queue.put_nowait, line)
119
+ loop.call_soon_threadsafe(queue.put_nowait, _DONE)
120
+ except BaseException as exc: # noqa: BLE001 - handed to the consumer, which re-raises
121
+ loop.call_soon_threadsafe(queue.put_nowait, exc)
122
+
123
+ threading.Thread(target=worker, daemon=True, name="agentcore-stream").start()
124
+ try:
125
+ while True:
126
+ item = await queue.get()
127
+ if item is _DONE:
128
+ return
129
+ if isinstance(item, BaseException):
130
+ raise item
131
+ yield item
132
+ finally:
133
+ stop.set() # the consumer went away (app closed, run cancelled): stop reading
134
+
135
+ def _invoke(self, payload: bytes, session_id: str, stop: threading.Event) -> Iterator[str]:
136
+ request: dict[str, Any] = {
137
+ "agentRuntimeArn": self.arn,
138
+ "runtimeSessionId": session_id,
139
+ "payload": payload,
140
+ "contentType": "application/json",
141
+ "accept": "text/event-stream",
142
+ }
143
+ if self.qualifier:
144
+ request["qualifier"] = self.qualifier
145
+
146
+ delay = 0.5
147
+ for attempt in range(_RETRIES + 1):
148
+ try:
149
+ response = self._client.invoke_agent_runtime(**request)
150
+ break
151
+ except Exception as exc: # noqa: BLE001 - mapped to a friendly BackendError below
152
+ code = (getattr(exc, "response", None) or {}).get("Error", {}).get("Code")
153
+ if code == "RetryableConflictException" and attempt < _RETRIES:
154
+ time.sleep(delay) # transient: the session is being started or torn down
155
+ delay *= 2
156
+ continue
157
+ raise explain(exc) from exc
158
+
159
+ stream = response["response"]
160
+ try:
161
+ content_type = str(response.get("contentType", ""))
162
+ if not content_type.startswith("text/event-stream"):
163
+ sample = stream.read(400).decode("utf-8", "replace").strip()
164
+ raise BackendError(
165
+ f"This runtime did not return an AG-UI event stream (content type {content_type or 'unknown'}). "
166
+ "Deploy it with the AG-UI protocol (serverProtocol AGUI). "
167
+ f"It said: {sample[:200]!r}"
168
+ )
169
+ # chunk_size=1: a larger chunk makes read() wait until that many bytes arrive, which would
170
+ # hold back a short event (a single token) until the next one shows up.
171
+ for raw in stream.iter_lines(chunk_size=1):
172
+ if stop.is_set():
173
+ return
174
+ yield raw.decode("utf-8", "replace")
175
+ except BackendError:
176
+ raise
177
+ except Exception as exc: # noqa: BLE001 - a read error mid-stream
178
+ raise explain(exc) from exc
179
+ finally:
180
+ close = getattr(stream, "close", None)
181
+ if close:
182
+ close()