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.
@@ -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.")