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 +1 -0
- gentui/cli.py +102 -0
- gentui/config.py +148 -0
- gentui/plugins.py +136 -0
- gentui/tui/__init__.py +0 -0
- gentui/tui/agentcore_client.py +182 -0
- gentui/tui/agui_client.py +119 -0
- gentui/tui/app.py +403 -0
- gentui/tui/branding.py +87 -0
- gentui/tui/commands.py +43 -0
- gentui/tui/splash.py +60 -0
- gentui/tui/widgets/__init__.py +3 -0
- gentui/tui/widgets/base.py +94 -0
- gentui/tui/widgets/chart.py +109 -0
- gentui/tui/widgets/command.py +143 -0
- gentui/tui/widgets/generic.py +80 -0
- gentui/tui/widgets/interrupt.py +65 -0
- gentui/tui/widgets/output.py +57 -0
- gentui/tui/widgets/plan.py +33 -0
- gentui/tui/widgets/registry.py +37 -0
- gentui/tui/widgets/table.py +34 -0
- gentui-0.1.0.dist-info/METADATA +293 -0
- gentui-0.1.0.dist-info/RECORD +26 -0
- gentui-0.1.0.dist-info/WHEEL +4 -0
- gentui-0.1.0.dist-info/entry_points.txt +4 -0
- gentui-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|