shinyhub-agent 0.1.1__tar.gz
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.
- shinyhub_agent-0.1.1/.gitignore +63 -0
- shinyhub_agent-0.1.1/LICENSE +21 -0
- shinyhub_agent-0.1.1/PKG-INFO +129 -0
- shinyhub_agent-0.1.1/README.md +106 -0
- shinyhub_agent-0.1.1/pyproject.toml +34 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/__init__.py +10 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_agui.py +139 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_chat.py +175 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_core.py +122 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_openai.py +149 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_session.py +58 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/_shiny.py +96 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/www/bridge.css +4 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/www/bridge.js +177 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/www/chat.css +9 -0
- shinyhub_agent-0.1.1/src/shinyhub_agent/www/chat.js +227 -0
- shinyhub_agent-0.1.1/tests/bridge.test.cjs +62 -0
- shinyhub_agent-0.1.1/tests/fixtures/chat_app.py +58 -0
- shinyhub_agent-0.1.1/tests/test_agui.py +114 -0
- shinyhub_agent-0.1.1/tests/test_chat.py +167 -0
- shinyhub_agent-0.1.1/tests/test_core.py +83 -0
- shinyhub_agent-0.1.1/tests/test_session.py +57 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
.superpowers/
|
|
2
|
+
docs/superpowers/
|
|
3
|
+
# Raw HTML under docs is an internal design/review artifact, not product
|
|
4
|
+
# documentation. Keep reports and their assets out of Git and deployments.
|
|
5
|
+
/docs/**/*.html
|
|
6
|
+
/docs/reports/
|
|
7
|
+
**report.html
|
|
8
|
+
/scripts/docs-smoke.py
|
|
9
|
+
.claire/
|
|
10
|
+
.codex/
|
|
11
|
+
.impeccable/
|
|
12
|
+
go.work
|
|
13
|
+
go.work.sum
|
|
14
|
+
bin/
|
|
15
|
+
tmp/
|
|
16
|
+
.worktrees/
|
|
17
|
+
.claude/investigations/
|
|
18
|
+
CLAUDE.md
|
|
19
|
+
CLAUDE.local.md
|
|
20
|
+
|
|
21
|
+
# Product positioning and brand direction, maintained by local tooling rather
|
|
22
|
+
# than as a repo artifact. Nothing in the build or the docs site reads it.
|
|
23
|
+
PRODUCT.md
|
|
24
|
+
|
|
25
|
+
# JS toolchain (only used for JSDOM tests of UI assets)
|
|
26
|
+
node_modules/
|
|
27
|
+
|
|
28
|
+
# Compiled server binary from `go build` at the repo root.
|
|
29
|
+
/shinyhub
|
|
30
|
+
|
|
31
|
+
# Runtime artifacts created when running the server from a checkout
|
|
32
|
+
# (default config writes the SQLite DB and per-app data under ./data).
|
|
33
|
+
/shinyhub.yaml
|
|
34
|
+
/data/
|
|
35
|
+
/site/
|
|
36
|
+
/deploy/demo/.env
|
|
37
|
+
*.db
|
|
38
|
+
*.db-shm
|
|
39
|
+
*.db-wal
|
|
40
|
+
|
|
41
|
+
# PyPI wheel build artifacts
|
|
42
|
+
/packaging/python/src/shinyhub/_binary/shinyhub
|
|
43
|
+
/packaging/python/dist/
|
|
44
|
+
/packaging/python/build/
|
|
45
|
+
/packaging/python/src/shinyhub.egg-info/
|
|
46
|
+
/packaging/python/__pycache__/
|
|
47
|
+
|
|
48
|
+
# Python bytecode (repo-wide; the render-rig test suites generate it)
|
|
49
|
+
__pycache__/
|
|
50
|
+
|
|
51
|
+
# Local investigation scratch. Not shipped, and it carries customer names
|
|
52
|
+
# that must never reach a committed file.
|
|
53
|
+
research/
|
|
54
|
+
|
|
55
|
+
# Local prototypes kept beside the repo, never part of a build.
|
|
56
|
+
experiments/
|
|
57
|
+
|
|
58
|
+
# Local font and branding source material. Production-ready application assets
|
|
59
|
+
# belong with the component that ships them, not in this repository-level tree.
|
|
60
|
+
/assets/
|
|
61
|
+
|
|
62
|
+
# macOS Finder metadata.
|
|
63
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruben J. Jongejan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: shinyhub-agent
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Session-scoped agent tools for Python Shiny apps on ShinyHub
|
|
5
|
+
Project-URL: Homepage, https://github.com/rvben/shinyhub
|
|
6
|
+
Project-URL: Repository, https://github.com/rvben/shinyhub
|
|
7
|
+
Project-URL: Issues, https://github.com/rvben/shinyhub/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ag-ui,agents,shiny,shinyhub,webmcp
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx<1,>=0.27
|
|
18
|
+
Requires-Dist: jsonschema<5,>=4.20
|
|
19
|
+
Requires-Dist: shiny<2,>=1.8.0
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# ShinyHub agent tools for Python Shiny
|
|
25
|
+
|
|
26
|
+
This helper lets an app declare a small, typed set of tools for the **current
|
|
27
|
+
viewer session**. The app owns all data access and state changes. ShinyHub does
|
|
28
|
+
not infer tools from visible inputs or let a model run arbitrary R/Python code.
|
|
29
|
+
The same registry powers built-in chat and browser WebMCP.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from shiny import App, reactive, ui
|
|
33
|
+
from shinyhub_agent import AgentTool, agent_dependency, register
|
|
34
|
+
|
|
35
|
+
app_ui = ui.page_fluid(agent_dependency(), ui.input_select("period", "Period", ["week", "year"]))
|
|
36
|
+
|
|
37
|
+
def server(input, output, session):
|
|
38
|
+
selected_period = reactive.value("week")
|
|
39
|
+
|
|
40
|
+
@reactive.effect
|
|
41
|
+
@reactive.event(input.period)
|
|
42
|
+
def from_control():
|
|
43
|
+
selected_period.set(input.period())
|
|
44
|
+
|
|
45
|
+
async def current_view(args):
|
|
46
|
+
return {"period": selected_period.get()}
|
|
47
|
+
|
|
48
|
+
async def set_period(args):
|
|
49
|
+
selected_period.set(args["period"])
|
|
50
|
+
ui.update_select("period", selected=args["period"], session=session)
|
|
51
|
+
return {"period": selected_period.get()}
|
|
52
|
+
|
|
53
|
+
register(session=session, input=input, tools=[
|
|
54
|
+
AgentTool("get_view", "Read the selected period", {
|
|
55
|
+
"type": "object", "properties": {}, "additionalProperties": False,
|
|
56
|
+
}, current_view),
|
|
57
|
+
AgentTool("set_period", "Change the selected period", {
|
|
58
|
+
"type": "object", "properties": {"period": {"type": "string", "enum": ["week", "year"]}},
|
|
59
|
+
"required": ["period"], "additionalProperties": False,
|
|
60
|
+
}, set_period, read_only=False, confirmation="Change the dashboard period?"),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
app = App(app_ui, server)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The helper sends version 1 capability messages over the existing Shiny session.
|
|
67
|
+
Arguments are validated again on the server with JSON Schema. Each session has
|
|
68
|
+
its own registry, nonce, concurrency lock, request budget, timeouts, and bounded
|
|
69
|
+
messages. Errors do not reveal handler exceptions or tool arguments. A handler
|
|
70
|
+
must perform its own permission checks for sensitive data or actions.
|
|
71
|
+
|
|
72
|
+
## Built-in chat or a hoster-owned agent
|
|
73
|
+
|
|
74
|
+
Add `chat_dependency()` to the UI and pass one chat backend to `register()`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import os
|
|
78
|
+
from shinyhub_agent import AGUIChat, OpenAIChat, chat_dependency
|
|
79
|
+
|
|
80
|
+
# Include chat_dependency() alongside agent_dependency() in the app UI.
|
|
81
|
+
if os.environ.get("SHINYHUB_AGENT_AGUI_URL"):
|
|
82
|
+
chat = AGUIChat(
|
|
83
|
+
endpoint=os.environ["SHINYHUB_AGENT_AGUI_URL"],
|
|
84
|
+
bearer_token=os.environ.get("SHINYHUB_AGENT_AGUI_TOKEN", ""),
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
chat = OpenAIChat(
|
|
88
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
89
|
+
instructions="You help with this dashboard. Use registered tools for app facts.",
|
|
90
|
+
)
|
|
91
|
+
register(session=session, input=input, tools=tools, chat=chat)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The app stores both credentials as private ShinyHub environment secrets. The
|
|
95
|
+
browser never receives them. OpenAI requests use the Responses API with
|
|
96
|
+
streaming, bounded output, and `store: false`. AG-UI requests carry the current
|
|
97
|
+
session's thread ID, recent messages, and registered tool schemas; they do not
|
|
98
|
+
carry ShinyHub cookies, identity headers, or other apps' data. The hoster must
|
|
99
|
+
authorize and secure their endpoint. Tool calls from either backend are
|
|
100
|
+
validated again by the app. A write pauses for visitor approval in the chat
|
|
101
|
+
panel, then runs the handler and returns its applied result to the agent.
|
|
102
|
+
|
|
103
|
+
When WebMCP is available, `bridge.js` registers the declared tools. In other
|
|
104
|
+
browsers it makes them available through `window.shinyhubAgentTools.invoke()`
|
|
105
|
+
for an app-supplied assistant. Browser tool writes show visitor confirmation
|
|
106
|
+
before dispatch. This confirmation is a browser interaction, **not a security
|
|
107
|
+
authorization boundary**; the app handler still decides what the viewer may do.
|
|
108
|
+
|
|
109
|
+
The chat history exists only in the viewer's Shiny session and is limited to
|
|
110
|
+
the last six exchanges. A new chat clears it. The app currently has no durable
|
|
111
|
+
conversation store. The helper does not provide a remote MCP server or a
|
|
112
|
+
platform-wide agent registry, administration UI, or billing controls.
|
|
113
|
+
|
|
114
|
+
## Operational requirements
|
|
115
|
+
|
|
116
|
+
- Keep app access behind ShinyHub's authentication and per-app access policy.
|
|
117
|
+
- Give tools the least authority required, and check the viewer's permissions
|
|
118
|
+
inside handlers for sensitive reads and actions.
|
|
119
|
+
- Return only data the viewer may see. Tool results are sent to the model
|
|
120
|
+
provider or hoster-owned AG-UI endpoint to compose an answer.
|
|
121
|
+
- Set model and endpoint secrets per app, never in the page or manifest.
|
|
122
|
+
- Review tool names, schemas, descriptions, and app instructions when the app
|
|
123
|
+
changes. Add a regression test for each consequential action.
|
|
124
|
+
- Run at most one chat request and one tool request at a time per viewer;
|
|
125
|
+
the adapter enforces per-session request budgets and bounded payloads.
|
|
126
|
+
|
|
127
|
+
The package is a reusable app integration. A platform-owned chat service,
|
|
128
|
+
central cost policy, admin configuration, and R Shiny helper remain separate
|
|
129
|
+
work before this becomes a platform-wide production feature.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# ShinyHub agent tools for Python Shiny
|
|
2
|
+
|
|
3
|
+
This helper lets an app declare a small, typed set of tools for the **current
|
|
4
|
+
viewer session**. The app owns all data access and state changes. ShinyHub does
|
|
5
|
+
not infer tools from visible inputs or let a model run arbitrary R/Python code.
|
|
6
|
+
The same registry powers built-in chat and browser WebMCP.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from shiny import App, reactive, ui
|
|
10
|
+
from shinyhub_agent import AgentTool, agent_dependency, register
|
|
11
|
+
|
|
12
|
+
app_ui = ui.page_fluid(agent_dependency(), ui.input_select("period", "Period", ["week", "year"]))
|
|
13
|
+
|
|
14
|
+
def server(input, output, session):
|
|
15
|
+
selected_period = reactive.value("week")
|
|
16
|
+
|
|
17
|
+
@reactive.effect
|
|
18
|
+
@reactive.event(input.period)
|
|
19
|
+
def from_control():
|
|
20
|
+
selected_period.set(input.period())
|
|
21
|
+
|
|
22
|
+
async def current_view(args):
|
|
23
|
+
return {"period": selected_period.get()}
|
|
24
|
+
|
|
25
|
+
async def set_period(args):
|
|
26
|
+
selected_period.set(args["period"])
|
|
27
|
+
ui.update_select("period", selected=args["period"], session=session)
|
|
28
|
+
return {"period": selected_period.get()}
|
|
29
|
+
|
|
30
|
+
register(session=session, input=input, tools=[
|
|
31
|
+
AgentTool("get_view", "Read the selected period", {
|
|
32
|
+
"type": "object", "properties": {}, "additionalProperties": False,
|
|
33
|
+
}, current_view),
|
|
34
|
+
AgentTool("set_period", "Change the selected period", {
|
|
35
|
+
"type": "object", "properties": {"period": {"type": "string", "enum": ["week", "year"]}},
|
|
36
|
+
"required": ["period"], "additionalProperties": False,
|
|
37
|
+
}, set_period, read_only=False, confirmation="Change the dashboard period?"),
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
app = App(app_ui, server)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The helper sends version 1 capability messages over the existing Shiny session.
|
|
44
|
+
Arguments are validated again on the server with JSON Schema. Each session has
|
|
45
|
+
its own registry, nonce, concurrency lock, request budget, timeouts, and bounded
|
|
46
|
+
messages. Errors do not reveal handler exceptions or tool arguments. A handler
|
|
47
|
+
must perform its own permission checks for sensitive data or actions.
|
|
48
|
+
|
|
49
|
+
## Built-in chat or a hoster-owned agent
|
|
50
|
+
|
|
51
|
+
Add `chat_dependency()` to the UI and pass one chat backend to `register()`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import os
|
|
55
|
+
from shinyhub_agent import AGUIChat, OpenAIChat, chat_dependency
|
|
56
|
+
|
|
57
|
+
# Include chat_dependency() alongside agent_dependency() in the app UI.
|
|
58
|
+
if os.environ.get("SHINYHUB_AGENT_AGUI_URL"):
|
|
59
|
+
chat = AGUIChat(
|
|
60
|
+
endpoint=os.environ["SHINYHUB_AGENT_AGUI_URL"],
|
|
61
|
+
bearer_token=os.environ.get("SHINYHUB_AGENT_AGUI_TOKEN", ""),
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
chat = OpenAIChat(
|
|
65
|
+
api_key=os.environ["OPENAI_API_KEY"],
|
|
66
|
+
instructions="You help with this dashboard. Use registered tools for app facts.",
|
|
67
|
+
)
|
|
68
|
+
register(session=session, input=input, tools=tools, chat=chat)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The app stores both credentials as private ShinyHub environment secrets. The
|
|
72
|
+
browser never receives them. OpenAI requests use the Responses API with
|
|
73
|
+
streaming, bounded output, and `store: false`. AG-UI requests carry the current
|
|
74
|
+
session's thread ID, recent messages, and registered tool schemas; they do not
|
|
75
|
+
carry ShinyHub cookies, identity headers, or other apps' data. The hoster must
|
|
76
|
+
authorize and secure their endpoint. Tool calls from either backend are
|
|
77
|
+
validated again by the app. A write pauses for visitor approval in the chat
|
|
78
|
+
panel, then runs the handler and returns its applied result to the agent.
|
|
79
|
+
|
|
80
|
+
When WebMCP is available, `bridge.js` registers the declared tools. In other
|
|
81
|
+
browsers it makes them available through `window.shinyhubAgentTools.invoke()`
|
|
82
|
+
for an app-supplied assistant. Browser tool writes show visitor confirmation
|
|
83
|
+
before dispatch. This confirmation is a browser interaction, **not a security
|
|
84
|
+
authorization boundary**; the app handler still decides what the viewer may do.
|
|
85
|
+
|
|
86
|
+
The chat history exists only in the viewer's Shiny session and is limited to
|
|
87
|
+
the last six exchanges. A new chat clears it. The app currently has no durable
|
|
88
|
+
conversation store. The helper does not provide a remote MCP server or a
|
|
89
|
+
platform-wide agent registry, administration UI, or billing controls.
|
|
90
|
+
|
|
91
|
+
## Operational requirements
|
|
92
|
+
|
|
93
|
+
- Keep app access behind ShinyHub's authentication and per-app access policy.
|
|
94
|
+
- Give tools the least authority required, and check the viewer's permissions
|
|
95
|
+
inside handlers for sensitive reads and actions.
|
|
96
|
+
- Return only data the viewer may see. Tool results are sent to the model
|
|
97
|
+
provider or hoster-owned AG-UI endpoint to compose an answer.
|
|
98
|
+
- Set model and endpoint secrets per app, never in the page or manifest.
|
|
99
|
+
- Review tool names, schemas, descriptions, and app instructions when the app
|
|
100
|
+
changes. Add a regression test for each consequential action.
|
|
101
|
+
- Run at most one chat request and one tool request at a time per viewer;
|
|
102
|
+
the adapter enforces per-session request budgets and bounded payloads.
|
|
103
|
+
|
|
104
|
+
The package is a reusable app integration. A platform-owned chat service,
|
|
105
|
+
central cost policy, admin configuration, and R Shiny helper remain separate
|
|
106
|
+
work before this becomes a platform-wide production feature.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "shinyhub-agent"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Session-scoped agent tools for Python Shiny apps on ShinyHub"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["shinyhub", "shiny", "agents", "webmcp", "ag-ui"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["shiny>=1.8.0,<2", "jsonschema>=4.20,<5", "httpx>=0.27,<1"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/rvben/shinyhub"
|
|
24
|
+
Repository = "https://github.com/rvben/shinyhub"
|
|
25
|
+
Issues = "https://github.com/rvben/shinyhub/issues"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=8"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/shinyhub_agent"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Explicit, session-scoped tools for app assistants and browser agents."""
|
|
2
|
+
|
|
3
|
+
from ._core import AgentTool, ToolError, ToolRegistry
|
|
4
|
+
from ._openai import OpenAIChat
|
|
5
|
+
from ._agui import AGUIChat
|
|
6
|
+
from ._chat import ChatAgent
|
|
7
|
+
from ._shiny import agent_dependency, chat_dependency, register
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.1"
|
|
10
|
+
__all__ = ["AGUIChat", "AgentTool", "ChatAgent", "OpenAIChat", "ToolError", "ToolRegistry", "agent_dependency", "chat_dependency", "register"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._core import ToolError, ToolRegistry
|
|
13
|
+
from ._openai import Approve
|
|
14
|
+
|
|
15
|
+
MAX_EVENT_BYTES = 65_536
|
|
16
|
+
MAX_RUN_BYTES = 1_048_576
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AGUIChat:
|
|
21
|
+
"""Connect a hoster-owned AG-UI endpoint to one Shiny viewer session."""
|
|
22
|
+
|
|
23
|
+
endpoint: str
|
|
24
|
+
bearer_token: str = field(default="", repr=False)
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
parsed = urlsplit(self.endpoint)
|
|
28
|
+
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
|
29
|
+
raise ValueError("AG-UI endpoints must be HTTPS URLs without embedded credentials")
|
|
30
|
+
|
|
31
|
+
async def run(self, message: str, history: list[dict[str, str]],
|
|
32
|
+
tools: ToolRegistry, approve: Approve, *, thread_id: str
|
|
33
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
34
|
+
if not isinstance(message, str) or not 1 <= len(message.strip()) <= 2_000:
|
|
35
|
+
raise ToolError("invalid_message", "Enter a question of at most 2,000 characters.")
|
|
36
|
+
messages = [
|
|
37
|
+
{"id": secrets.token_urlsafe(10), "role": item["role"],
|
|
38
|
+
"content": item["content"][:2_000]}
|
|
39
|
+
for item in history[-12:]
|
|
40
|
+
if item.get("role") in ("user", "assistant") and isinstance(item.get("content"), str)
|
|
41
|
+
]
|
|
42
|
+
messages.append({"id": secrets.token_urlsafe(10), "role": "user", "content": message})
|
|
43
|
+
offered = [{
|
|
44
|
+
"name": spec["name"], "description": spec["description"],
|
|
45
|
+
"parameters": spec["inputSchema"],
|
|
46
|
+
} for spec in tools.public_spec()["tools"]]
|
|
47
|
+
headers = {"Accept": "text/event-stream", "Content-Type": "application/json"}
|
|
48
|
+
if self.bearer_token:
|
|
49
|
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
50
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(45, connect=5)) as client:
|
|
51
|
+
for _ in range(4):
|
|
52
|
+
calls: dict[str, dict[str, Any]] = {}
|
|
53
|
+
run_finished = False
|
|
54
|
+
text_seen = False
|
|
55
|
+
total = 0
|
|
56
|
+
async with client.stream("POST", self.endpoint, headers=headers, json={
|
|
57
|
+
"threadId": thread_id,
|
|
58
|
+
"runId": secrets.token_urlsafe(16),
|
|
59
|
+
"state": {},
|
|
60
|
+
"messages": messages,
|
|
61
|
+
"tools": offered,
|
|
62
|
+
"context": [],
|
|
63
|
+
"forwardedProps": {},
|
|
64
|
+
}) as response:
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
data_lines: list[str] = []
|
|
67
|
+
async for line in response.aiter_lines():
|
|
68
|
+
total += len(line.encode())
|
|
69
|
+
if total > MAX_RUN_BYTES:
|
|
70
|
+
raise ToolError("agent_too_large", "The agent sent too much data.")
|
|
71
|
+
if line.startswith("data:"):
|
|
72
|
+
data_lines.append(line[5:].lstrip())
|
|
73
|
+
if sum(map(len, data_lines)) > MAX_EVENT_BYTES:
|
|
74
|
+
raise ToolError("agent_too_large", "The agent event was too large.")
|
|
75
|
+
continue
|
|
76
|
+
if line or not data_lines:
|
|
77
|
+
continue
|
|
78
|
+
raw_event = "\n".join(data_lines)
|
|
79
|
+
data_lines.clear()
|
|
80
|
+
event = json.loads(raw_event)
|
|
81
|
+
kind = event.get("type")
|
|
82
|
+
if kind in ("TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK"):
|
|
83
|
+
delta = event.get("delta")
|
|
84
|
+
if isinstance(delta, str) and delta:
|
|
85
|
+
text_seen = True
|
|
86
|
+
yield {"type": "delta", "text": delta}
|
|
87
|
+
elif kind == "RUN_STARTED":
|
|
88
|
+
yield {"type": "status", "text": "Agent is working"}
|
|
89
|
+
elif kind == "TOOL_CALL_START":
|
|
90
|
+
call_id = event.get("toolCallId")
|
|
91
|
+
if not isinstance(call_id, str) or len(call_id) > 100:
|
|
92
|
+
raise ToolError("bad_agent_event", "The agent sent an invalid tool call.")
|
|
93
|
+
calls[call_id] = {"name": event.get("toolCallName"), "arguments": "", "ended": False}
|
|
94
|
+
yield {"type": "status", "text": "Using app tools"}
|
|
95
|
+
elif kind == "TOOL_CALL_ARGS":
|
|
96
|
+
call = calls.get(event.get("toolCallId"))
|
|
97
|
+
if call is None or not isinstance(event.get("delta"), str):
|
|
98
|
+
raise ToolError("bad_agent_event", "The agent sent invalid tool arguments.")
|
|
99
|
+
call["arguments"] += event["delta"]
|
|
100
|
+
if len(call["arguments"].encode()) > 8_192:
|
|
101
|
+
raise ToolError("bad_agent_event", "The agent tool arguments were too large.")
|
|
102
|
+
elif kind == "TOOL_CALL_END":
|
|
103
|
+
call = calls.get(event.get("toolCallId"))
|
|
104
|
+
if call is None:
|
|
105
|
+
raise ToolError("bad_agent_event", "The agent ended an unknown tool call.")
|
|
106
|
+
call["ended"] = True
|
|
107
|
+
elif kind == "TOOL_CALL_RESULT":
|
|
108
|
+
calls.pop(event.get("toolCallId"), None)
|
|
109
|
+
elif kind == "RUN_ERROR":
|
|
110
|
+
raise ToolError("agent_failed", "The connected agent could not finish.")
|
|
111
|
+
elif kind == "RUN_FINISHED":
|
|
112
|
+
run_finished = True
|
|
113
|
+
if not run_finished:
|
|
114
|
+
raise ToolError("agent_failed", "The connected agent ended early.")
|
|
115
|
+
if not calls:
|
|
116
|
+
if not text_seen:
|
|
117
|
+
raise ToolError("agent_failed", "The connected agent returned no answer.")
|
|
118
|
+
return
|
|
119
|
+
if len(calls) > 2 or any(not call["ended"] for call in calls.values()):
|
|
120
|
+
raise ToolError("bad_agent_event", "The agent sent incomplete tool calls.")
|
|
121
|
+
for call_id, call in calls.items():
|
|
122
|
+
tool = tools.get(call["name"])
|
|
123
|
+
try:
|
|
124
|
+
arguments = json.loads(call["arguments"])
|
|
125
|
+
if tool is None:
|
|
126
|
+
result: Any = {"error": "Tool unavailable"}
|
|
127
|
+
elif tool.read_only:
|
|
128
|
+
result = await tools.execute(tool.name, arguments)
|
|
129
|
+
else:
|
|
130
|
+
result = await approve(tool.name, arguments)
|
|
131
|
+
yield {"type": "action_applied", "name": tool.name, "result": result}
|
|
132
|
+
except (ValueError, ToolError) as error:
|
|
133
|
+
result = {"error": str(error)}
|
|
134
|
+
messages.append({
|
|
135
|
+
"id": secrets.token_urlsafe(10), "role": "tool",
|
|
136
|
+
"toolCallId": call_id, "content": json.dumps(result),
|
|
137
|
+
})
|
|
138
|
+
yield {"type": "status", "text": "Writing answer"}
|
|
139
|
+
raise ToolError("agent_failed", "The connected agent did not finish.")
|