getbsquare 0.1.0__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.
- getbsquare-0.1.0/.coverage +0 -0
- getbsquare-0.1.0/.gitignore +5 -0
- getbsquare-0.1.0/PKG-INFO +20 -0
- getbsquare-0.1.0/README.md +4 -0
- getbsquare-0.1.0/bsquare_host/__init__.py +19 -0
- getbsquare-0.1.0/bsquare_host/app.py +39 -0
- getbsquare-0.1.0/bsquare_host/host_actions.py +317 -0
- getbsquare-0.1.0/bsquare_host/state.py +20 -0
- getbsquare-0.1.0/pyproject.toml +22 -0
- getbsquare-0.1.0/tests/test_app.py +57 -0
- getbsquare-0.1.0/tests/test_host_actions.py +160 -0
- getbsquare-0.1.0/tests/test_public_api.py +12 -0
- getbsquare-0.1.0/tests/test_state.py +14 -0
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getbsquare
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend host-action coordination for injectable AG-UI / PydanticAI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: fastapi>=0.110
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Provides-Extra: examples
|
|
13
|
+
Requires-Dist: pydantic-ai; extra == 'examples'
|
|
14
|
+
Requires-Dist: uvicorn[standard]; extra == 'examples'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# bsquare-host
|
|
18
|
+
|
|
19
|
+
Backend host-action coordination for injectable AG-UI / PydanticAI agents.
|
|
20
|
+
See the repo root README for the full picture.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""bsquare-host: backend host-action coordination for injectable agents."""
|
|
2
|
+
|
|
3
|
+
from .app import mount_agent_app
|
|
4
|
+
from .host_actions import (
|
|
5
|
+
get_coordination_stats,
|
|
6
|
+
host_action_proxy,
|
|
7
|
+
setup_host_action_endpoint,
|
|
8
|
+
)
|
|
9
|
+
from .state import AgentState
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"host_action_proxy",
|
|
13
|
+
"setup_host_action_endpoint",
|
|
14
|
+
"get_coordination_stats",
|
|
15
|
+
"mount_agent_app",
|
|
16
|
+
"AgentState",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""One-call wiring for an injectable agent backend."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional, Sequence
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
7
|
+
|
|
8
|
+
from .host_actions import setup_host_action_endpoint
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def mount_agent_app(
|
|
12
|
+
agent,
|
|
13
|
+
app: FastAPI,
|
|
14
|
+
deps_factory: Callable[[], object],
|
|
15
|
+
*,
|
|
16
|
+
path: str = "/agent",
|
|
17
|
+
cors_origins: Optional[Sequence[str]] = None,
|
|
18
|
+
) -> FastAPI:
|
|
19
|
+
"""Wire an AG-UI agent, the host-action endpoint, and (optionally) CORS.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
agent: an object exposing ``to_ag_ui(deps=...)`` (e.g. a PydanticAI Agent).
|
|
23
|
+
app: the FastAPI app to mount onto.
|
|
24
|
+
deps_factory: zero-arg callable returning the agent's deps instance.
|
|
25
|
+
Called once at mount time (not per request).
|
|
26
|
+
path: mount path for the AG-UI app (default ``/agent``).
|
|
27
|
+
cors_origins: if given, a permissive CORS middleware is added for them.
|
|
28
|
+
"""
|
|
29
|
+
if cors_origins is not None:
|
|
30
|
+
app.add_middleware(
|
|
31
|
+
CORSMiddleware,
|
|
32
|
+
allow_origins=list(cors_origins),
|
|
33
|
+
allow_credentials=True,
|
|
34
|
+
allow_methods=["*"],
|
|
35
|
+
allow_headers=["*"],
|
|
36
|
+
)
|
|
37
|
+
setup_host_action_endpoint(app)
|
|
38
|
+
app.mount(path, agent.to_ag_ui(deps=deps_factory()))
|
|
39
|
+
return app
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host action coordination system for injectable AG-UI / PydanticAI agents.
|
|
3
|
+
|
|
4
|
+
This module provides the production-ready @host_action_proxy decorator and coordination
|
|
5
|
+
infrastructure that lets backend tools trigger an action on the host page (via the
|
|
6
|
+
injectable widget) and wait for its result, coordinated per tool_call_id.
|
|
7
|
+
|
|
8
|
+
Usage in any agent:
|
|
9
|
+
from bsquare_host import host_action_proxy, setup_host_action_endpoint
|
|
10
|
+
|
|
11
|
+
@your_agent.tool
|
|
12
|
+
@host_action_proxy(timeout=3.0)
|
|
13
|
+
async def get_page_info(ctx: RunContext[YourDeps], host_result: dict = None) -> str:
|
|
14
|
+
if host_result and host_result.get('success'):
|
|
15
|
+
return f"Retrieved page information: {host_result.get('result')}"
|
|
16
|
+
return "Unable to retrieve page information at this time."
|
|
17
|
+
|
|
18
|
+
# Add to your FastAPI app:
|
|
19
|
+
setup_host_action_endpoint(app)
|
|
20
|
+
|
|
21
|
+
Diagnostic messages are emitted via the ``bsquare_host`` logger at DEBUG level, so the
|
|
22
|
+
library stays silent by default. Enable them with::
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
logging.getLogger("bsquare_host").setLevel(logging.DEBUG)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import inspect
|
|
30
|
+
import logging
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from typing import Dict, Any, Callable
|
|
34
|
+
from fastapi import FastAPI
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("bsquare_host")
|
|
37
|
+
|
|
38
|
+
# Global coordination state - shared across all agents using this module
|
|
39
|
+
# This is intentionally global to allow coordination between:
|
|
40
|
+
# 1. Multiple decorated tools within an agent
|
|
41
|
+
# 2. The /host-action-result endpoint
|
|
42
|
+
# 3. Frontend widget result submissions
|
|
43
|
+
host_action_results: Dict[str, Any] = {}
|
|
44
|
+
pending_host_actions: Dict[str, asyncio.Event] = {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def host_action_proxy(timeout: float = 5.0, auto_format: bool = False):
|
|
48
|
+
"""
|
|
49
|
+
Production-ready decorator for backend tools that coordinate with frontend host actions.
|
|
50
|
+
|
|
51
|
+
Features:
|
|
52
|
+
- Multi-user safe using tool_call_id coordination
|
|
53
|
+
- Automatic timeout handling and cleanup
|
|
54
|
+
- Zero race conditions between concurrent users
|
|
55
|
+
- Graceful error handling and fallback responses
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
timeout: Maximum time to wait for host action result (default: 5.0 seconds)
|
|
59
|
+
auto_format: If True, automatically handle timeout/success/error responses (default: False)
|
|
60
|
+
|
|
61
|
+
Usage Option 1 - Manual handling (existing pattern):
|
|
62
|
+
@your_agent.tool
|
|
63
|
+
@host_action_proxy(timeout=3.0)
|
|
64
|
+
async def navigate_to_page(ctx: RunContext[YourDeps], page: str, host_result: dict = None) -> str:
|
|
65
|
+
if host_result and host_result.get('timeout'):
|
|
66
|
+
return f"Navigation to {page} is taking longer than expected..."
|
|
67
|
+
|
|
68
|
+
if host_result and host_result.get('success'):
|
|
69
|
+
result = host_result.get('result', {})
|
|
70
|
+
if result.get('success'):
|
|
71
|
+
return f"Navigation completed: {result}"
|
|
72
|
+
|
|
73
|
+
return f"Unable to navigate to {page} page at this time."
|
|
74
|
+
|
|
75
|
+
Usage Option 2 - Auto-format (eliminates boilerplate):
|
|
76
|
+
@your_agent.tool
|
|
77
|
+
@host_action_proxy(timeout=3.0, auto_format=True)
|
|
78
|
+
async def navigate_to_page(ctx: RunContext[YourDeps], page: str) -> dict:
|
|
79
|
+
'''Navigate to a specific page in the construction management system.'''
|
|
80
|
+
return {
|
|
81
|
+
"action": "navigate_to_page",
|
|
82
|
+
"params": {"page": page},
|
|
83
|
+
"timeout_message": f"Navigation to {page} is taking longer than expected...",
|
|
84
|
+
"error_message": f"Unable to navigate to {page} page at this time."
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
def decorator(func: Callable) -> Callable:
|
|
88
|
+
# The wrapper below is generic (ctx, *args, **kwargs), but agent
|
|
89
|
+
# frameworks (e.g. PydanticAI) build each tool's JSON schema by
|
|
90
|
+
# introspecting the function signature. If they see (*args, **kwargs)
|
|
91
|
+
# the model is never told the real parameters and calls the tool with
|
|
92
|
+
# none -- so the host action runs against empty params. Advertise the
|
|
93
|
+
# wrapped function's real signature instead, hiding the internal
|
|
94
|
+
# host_result coordination parameter.
|
|
95
|
+
_orig_sig = inspect.signature(func)
|
|
96
|
+
_public_params = [
|
|
97
|
+
p for name, p in _orig_sig.parameters.items() if name != "host_result"
|
|
98
|
+
]
|
|
99
|
+
_public_sig = _orig_sig.replace(parameters=_public_params)
|
|
100
|
+
_public_annotations = {
|
|
101
|
+
k: v
|
|
102
|
+
for k, v in getattr(func, "__annotations__", {}).items()
|
|
103
|
+
if k != "host_result"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def wrapper(ctx, *args, **kwargs):
|
|
107
|
+
action_name = func.__name__
|
|
108
|
+
logger.debug(f"Host Action: {action_name} decorator wrapper called")
|
|
109
|
+
|
|
110
|
+
# Get tool call ID from context - this is unique per tool call
|
|
111
|
+
tool_call_id = ctx.tool_call_id
|
|
112
|
+
if not tool_call_id:
|
|
113
|
+
# Fallback to generating unique ID if not available
|
|
114
|
+
tool_call_id = str(uuid.uuid4())
|
|
115
|
+
|
|
116
|
+
logger.debug(f"Host Action: Using tool_call_id: {tool_call_id}")
|
|
117
|
+
|
|
118
|
+
# Set up waiting mechanism using unique tool_call_id
|
|
119
|
+
pending_host_actions[tool_call_id] = asyncio.Event()
|
|
120
|
+
|
|
121
|
+
# Extract parameters from function signature
|
|
122
|
+
sig = _orig_sig
|
|
123
|
+
func_params = {}
|
|
124
|
+
|
|
125
|
+
# Get parameters excluding ctx and host_result
|
|
126
|
+
param_names = [p for p in sig.parameters.keys() if p not in ['ctx', 'host_result']]
|
|
127
|
+
for i, param_name in enumerate(param_names):
|
|
128
|
+
if i < len(args):
|
|
129
|
+
func_params[param_name] = args[i]
|
|
130
|
+
|
|
131
|
+
# Only add non-host_result kwargs to func_params
|
|
132
|
+
filtered_kwargs = {k: v for k, v in kwargs.items() if k != 'host_result'}
|
|
133
|
+
func_params.update(filtered_kwargs)
|
|
134
|
+
|
|
135
|
+
# Store clean args and kwargs for function calls
|
|
136
|
+
clean_args = args[:len(param_names)]
|
|
137
|
+
clean_kwargs = filtered_kwargs
|
|
138
|
+
|
|
139
|
+
# Set pending host action in state for frontend (include tool_call_id)
|
|
140
|
+
# Handle both dict and dataclass state objects
|
|
141
|
+
if hasattr(ctx.deps.state, 'pending_host_action'):
|
|
142
|
+
# Dataclass-style state
|
|
143
|
+
ctx.deps.state.pending_host_action = {
|
|
144
|
+
"action": action_name,
|
|
145
|
+
"params": func_params,
|
|
146
|
+
"tool_call_id": tool_call_id,
|
|
147
|
+
"timestamp": time.time()
|
|
148
|
+
}
|
|
149
|
+
else:
|
|
150
|
+
# Dict-style state
|
|
151
|
+
ctx.deps.state["pending_host_action"] = {
|
|
152
|
+
"action": action_name,
|
|
153
|
+
"params": func_params,
|
|
154
|
+
"tool_call_id": tool_call_id,
|
|
155
|
+
"timestamp": time.time()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
logger.debug(f"Host Action: Set pending_host_action for {action_name} with tool_call_id {tool_call_id}, waiting for result...")
|
|
159
|
+
|
|
160
|
+
# Wait for host action result with timeout
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.wait_for(
|
|
163
|
+
pending_host_actions[tool_call_id].wait(),
|
|
164
|
+
timeout=timeout
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Get result using tool_call_id
|
|
168
|
+
action_result = host_action_results.get(tool_call_id)
|
|
169
|
+
|
|
170
|
+
if action_result:
|
|
171
|
+
result = action_result.get('result', {})
|
|
172
|
+
logger.debug(f"Host Action: Found {action_name} result for tool_call_id {tool_call_id}: {result}")
|
|
173
|
+
|
|
174
|
+
# Clean up
|
|
175
|
+
del pending_host_actions[tool_call_id]
|
|
176
|
+
del host_action_results[tool_call_id]
|
|
177
|
+
|
|
178
|
+
# Handle auto-format vs manual mode
|
|
179
|
+
if auto_format:
|
|
180
|
+
return _auto_format_response(action_result, action_name, func)
|
|
181
|
+
else:
|
|
182
|
+
# Call original function with host_result
|
|
183
|
+
return await func(ctx, host_result=action_result)
|
|
184
|
+
else:
|
|
185
|
+
logger.debug(f"Host Action: No result found for {action_name} with tool_call_id {tool_call_id}")
|
|
186
|
+
if auto_format:
|
|
187
|
+
# Get function metadata for error handling
|
|
188
|
+
func_result = await func(ctx, *clean_args, **clean_kwargs)
|
|
189
|
+
if isinstance(func_result, dict) and 'error_message' in func_result:
|
|
190
|
+
return func_result['error_message']
|
|
191
|
+
return f"Unable to complete {action_name} at this time."
|
|
192
|
+
else:
|
|
193
|
+
return await func(ctx, host_result=None)
|
|
194
|
+
|
|
195
|
+
except asyncio.TimeoutError:
|
|
196
|
+
logger.debug(f"Host Action: {action_name} timed out after {timeout}s (tool_call_id: {tool_call_id})")
|
|
197
|
+
# Clean up
|
|
198
|
+
if tool_call_id in pending_host_actions:
|
|
199
|
+
del pending_host_actions[tool_call_id]
|
|
200
|
+
|
|
201
|
+
if auto_format:
|
|
202
|
+
# Get function metadata for timeout handling
|
|
203
|
+
func_result = await func(ctx, *clean_args, **clean_kwargs)
|
|
204
|
+
if isinstance(func_result, dict) and 'timeout_message' in func_result:
|
|
205
|
+
return func_result['timeout_message']
|
|
206
|
+
return f"{action_name} is taking longer than expected..."
|
|
207
|
+
else:
|
|
208
|
+
# Call function with timeout indication
|
|
209
|
+
return await func(ctx, host_result={'timeout': True})
|
|
210
|
+
|
|
211
|
+
# Preserve original function metadata and advertise the real signature
|
|
212
|
+
# (minus host_result) so tool-schema builders see the true parameters.
|
|
213
|
+
wrapper.__name__ = func.__name__
|
|
214
|
+
wrapper.__doc__ = func.__doc__
|
|
215
|
+
wrapper.__annotations__ = _public_annotations
|
|
216
|
+
wrapper.__signature__ = _public_sig
|
|
217
|
+
|
|
218
|
+
return wrapper
|
|
219
|
+
return decorator
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _auto_format_response(action_result: dict, action_name: str, func: Callable) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Auto-format host action responses based on success/failure patterns.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
action_result: Result from host action execution
|
|
228
|
+
action_name: Name of the action that was executed
|
|
229
|
+
func: Original function for extracting custom messages
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Formatted response string
|
|
233
|
+
"""
|
|
234
|
+
if action_result.get('success'):
|
|
235
|
+
result = action_result.get('result', {})
|
|
236
|
+
if result.get('success'):
|
|
237
|
+
# Format successful result - show data if meaningful
|
|
238
|
+
if isinstance(result, dict) and len(result) > 1:
|
|
239
|
+
return f"✅ {action_name.replace('_', ' ').title()} completed successfully: {result}"
|
|
240
|
+
else:
|
|
241
|
+
return f"✅ {action_name.replace('_', ' ').title()} completed successfully"
|
|
242
|
+
else:
|
|
243
|
+
# Host action succeeded but returned failure
|
|
244
|
+
error_msg = result.get('error', 'Unknown error')
|
|
245
|
+
return f"❌ {action_name.replace('_', ' ').title()} failed: {error_msg}"
|
|
246
|
+
else:
|
|
247
|
+
# Host action itself failed
|
|
248
|
+
error_msg = action_result.get('error', 'Host action execution failed')
|
|
249
|
+
return f"❌ {action_name.replace('_', ' ').title()} failed: {error_msg}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def setup_host_action_endpoint(app: FastAPI):
|
|
253
|
+
"""
|
|
254
|
+
Add the /host-action-result endpoint to a FastAPI app.
|
|
255
|
+
|
|
256
|
+
This endpoint receives results from frontend host actions and coordinates
|
|
257
|
+
with waiting backend tools through the global coordination system.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
app: FastAPI application instance
|
|
261
|
+
|
|
262
|
+
Usage:
|
|
263
|
+
from bsquare_host import setup_host_action_endpoint
|
|
264
|
+
|
|
265
|
+
app = FastAPI()
|
|
266
|
+
setup_host_action_endpoint(app)
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
@app.post("/host-action-result")
|
|
270
|
+
async def receive_host_action_result(request: dict):
|
|
271
|
+
"""Unified endpoint for receiving host action results from frontend widgets."""
|
|
272
|
+
logger.debug(f"Host Action: Received result: {request.get('toolName')} -> {request.get('result')}")
|
|
273
|
+
|
|
274
|
+
# Extract coordination data
|
|
275
|
+
tool_call_id = request.get('toolCallId')
|
|
276
|
+
tool_name = request.get('toolName')
|
|
277
|
+
result = request.get('result')
|
|
278
|
+
success = request.get('success', False)
|
|
279
|
+
|
|
280
|
+
if not tool_call_id:
|
|
281
|
+
return {"status": "error", "detail": "toolCallId is required"}
|
|
282
|
+
|
|
283
|
+
# Store result and notify waiting tools
|
|
284
|
+
host_action_results[tool_call_id] = {
|
|
285
|
+
'result': result,
|
|
286
|
+
'success': success,
|
|
287
|
+
'toolName': tool_name
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# Notify waiting tool if exists (using tool_call_id)
|
|
291
|
+
if tool_call_id in pending_host_actions:
|
|
292
|
+
logger.debug(f"Host Action: Found waiting tool for tool_call_id '{tool_call_id}', setting event!")
|
|
293
|
+
pending_host_actions[tool_call_id].set()
|
|
294
|
+
else:
|
|
295
|
+
logger.debug(f"Host Action: No waiting tool found for tool_call_id '{tool_call_id}'")
|
|
296
|
+
|
|
297
|
+
if success:
|
|
298
|
+
logger.debug(f"Host action {tool_name} completed successfully")
|
|
299
|
+
else:
|
|
300
|
+
logger.debug(f"Host action {tool_name} failed")
|
|
301
|
+
|
|
302
|
+
return {"status": "received", "toolCallId": tool_call_id}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def get_coordination_stats() -> Dict[str, Any]:
|
|
306
|
+
"""
|
|
307
|
+
Get current coordination system statistics for debugging.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Dictionary with current state of coordination system
|
|
311
|
+
"""
|
|
312
|
+
return {
|
|
313
|
+
"pending_actions": len(pending_host_actions),
|
|
314
|
+
"stored_results": len(host_action_results),
|
|
315
|
+
"pending_tool_call_ids": list(pending_host_actions.keys()),
|
|
316
|
+
"result_tool_call_ids": list(host_action_results.keys())
|
|
317
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Base state for agents that coordinate with frontend host actions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AgentState:
|
|
9
|
+
"""Carry the fields the host-action handshake reads and writes.
|
|
10
|
+
|
|
11
|
+
- ``pending_host_action`` is set by :func:`host_action_proxy` so the
|
|
12
|
+
frontend knows which action to run.
|
|
13
|
+
- ``host_action_result`` mirrors the frontend's reply (the canonical copy
|
|
14
|
+
lives in the coordination map keyed by ``tool_call_id``).
|
|
15
|
+
- ``api_token`` is forwarded by the widget in the AG-UI ``state`` field.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
pending_host_action: Optional[Dict[str, Any]] = None
|
|
19
|
+
host_action_result: Optional[Dict[str, Any]] = None
|
|
20
|
+
api_token: Optional[str] = None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "getbsquare"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Backend host-action coordination for injectable AG-UI / PydanticAI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["fastapi>=0.110"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27"]
|
|
16
|
+
examples = ["pydantic-ai", "uvicorn[standard]"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["bsquare_host"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from bsquare_host.app import mount_agent_app
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _State:
|
|
12
|
+
pending_host_action: Optional[Dict[str, Any]] = None
|
|
13
|
+
host_action_result: Optional[Dict[str, Any]] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class _Deps:
|
|
18
|
+
state: _State
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _FakeAgent:
|
|
22
|
+
"""Stands in for a PydanticAI agent; mirrors the .to_ag_ui() contract."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.deps_passed = None
|
|
26
|
+
|
|
27
|
+
def to_ag_ui(self, deps=None):
|
|
28
|
+
self.deps_passed = deps
|
|
29
|
+
sub = FastAPI()
|
|
30
|
+
|
|
31
|
+
@sub.get("/")
|
|
32
|
+
def root():
|
|
33
|
+
return {"ok": True}
|
|
34
|
+
|
|
35
|
+
return sub
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_mount_agent_app_wires_endpoints_and_mount():
|
|
39
|
+
app = FastAPI()
|
|
40
|
+
agent = _FakeAgent()
|
|
41
|
+
mount_agent_app(agent, app, lambda: _Deps(_State()), cors_origins=["*"])
|
|
42
|
+
client = TestClient(app)
|
|
43
|
+
|
|
44
|
+
# host-action-result endpoint is present
|
|
45
|
+
r = client.post(
|
|
46
|
+
"/host-action-result",
|
|
47
|
+
json={"toolCallId": "x", "toolName": "t", "result": {}, "success": True},
|
|
48
|
+
)
|
|
49
|
+
assert r.status_code == 200
|
|
50
|
+
|
|
51
|
+
# agent is mounted at /agent
|
|
52
|
+
r2 = client.get("/agent/")
|
|
53
|
+
assert r2.status_code == 200
|
|
54
|
+
assert r2.json() == {"ok": True}
|
|
55
|
+
|
|
56
|
+
# deps factory was invoked and passed through
|
|
57
|
+
assert isinstance(agent.deps_passed, _Deps)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from bsquare_host.host_actions import (
|
|
10
|
+
host_action_proxy,
|
|
11
|
+
setup_host_action_endpoint,
|
|
12
|
+
pending_host_actions,
|
|
13
|
+
host_action_results,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _State:
|
|
19
|
+
pending_host_action: Optional[Dict[str, Any]] = None
|
|
20
|
+
host_action_result: Optional[Dict[str, Any]] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _Deps:
|
|
25
|
+
state: _State
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Ctx:
|
|
29
|
+
def __init__(self, tool_call_id, deps):
|
|
30
|
+
self.tool_call_id = tool_call_id
|
|
31
|
+
self.deps = deps
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@host_action_proxy(timeout=2.0, auto_format=True)
|
|
35
|
+
async def navigate_to_page(ctx, page: str) -> str:
|
|
36
|
+
return f"Navigated to {page}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@host_action_proxy(timeout=0.2, auto_format=True)
|
|
40
|
+
async def slow_action(ctx) -> str:
|
|
41
|
+
return "done"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@host_action_proxy(timeout=2.0)
|
|
45
|
+
async def manual_action(ctx, page: str, host_result: dict = None) -> str:
|
|
46
|
+
return f"manual {page}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_decorator_exposes_wrapped_parameters_to_schema():
|
|
50
|
+
# PydanticAI (and any tool framework) builds a tool's JSON schema by
|
|
51
|
+
# introspecting the function signature. The decorator must advertise the
|
|
52
|
+
# wrapped function's real parameters -- NOT the wrapper's (ctx, *args,
|
|
53
|
+
# **kwargs) -- otherwise the model is never told the tool takes `page` and
|
|
54
|
+
# calls it with no arguments (the action then runs against empty params).
|
|
55
|
+
sig = inspect.signature(navigate_to_page)
|
|
56
|
+
assert "page" in sig.parameters, "the 'page' parameter must be visible to the schema builder"
|
|
57
|
+
assert sig.parameters["page"].annotation is str
|
|
58
|
+
kinds = {p.kind for p in sig.parameters.values()}
|
|
59
|
+
assert inspect.Parameter.VAR_POSITIONAL not in kinds, "*args must not leak into the tool schema"
|
|
60
|
+
assert inspect.Parameter.VAR_KEYWORD not in kinds, "**kwargs must not leak into the tool schema"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_decorator_hides_internal_host_result_param():
|
|
64
|
+
# `host_result` is an internal coordination channel, not a model-facing
|
|
65
|
+
# argument; it must never appear in the advertised signature.
|
|
66
|
+
sig = inspect.signature(manual_action)
|
|
67
|
+
assert "page" in sig.parameters
|
|
68
|
+
assert "host_result" not in sig.parameters
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_handshake_accepts_keyword_arguments():
|
|
72
|
+
# PydanticAI invokes tools with keyword arguments, so the params advertised
|
|
73
|
+
# to the frontend must be captured from kwargs, not only positional args.
|
|
74
|
+
ctx = _Ctx("call-kw", _Deps(_State()))
|
|
75
|
+
task = asyncio.create_task(navigate_to_page(ctx, page="reports"))
|
|
76
|
+
await asyncio.sleep(0.05)
|
|
77
|
+
|
|
78
|
+
assert ctx.deps.state.pending_host_action["params"] == {"page": "reports"}
|
|
79
|
+
|
|
80
|
+
host_action_results["call-kw"] = {
|
|
81
|
+
"result": {"success": True, "page": "reports"},
|
|
82
|
+
"success": True,
|
|
83
|
+
"toolName": "navigate_to_page",
|
|
84
|
+
}
|
|
85
|
+
pending_host_actions["call-kw"].set()
|
|
86
|
+
|
|
87
|
+
result = await task
|
|
88
|
+
assert "completed successfully" in result
|
|
89
|
+
assert "call-kw" not in pending_host_actions
|
|
90
|
+
assert "call-kw" not in host_action_results
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_handshake_returns_formatted_success():
|
|
94
|
+
ctx = _Ctx("call-1", _Deps(_State()))
|
|
95
|
+
task = asyncio.create_task(navigate_to_page(ctx, "reports"))
|
|
96
|
+
await asyncio.sleep(0.05) # let the decorator register the pending action
|
|
97
|
+
|
|
98
|
+
# The decorator must have advertised the pending action on state:
|
|
99
|
+
assert ctx.deps.state.pending_host_action["action"] == "navigate_to_page"
|
|
100
|
+
assert ctx.deps.state.pending_host_action["params"] == {"page": "reports"}
|
|
101
|
+
|
|
102
|
+
# Simulate the frontend posting its result (what the endpoint does):
|
|
103
|
+
host_action_results["call-1"] = {
|
|
104
|
+
"result": {"success": True, "page": "reports"},
|
|
105
|
+
"success": True,
|
|
106
|
+
"toolName": "navigate_to_page",
|
|
107
|
+
}
|
|
108
|
+
pending_host_actions["call-1"].set()
|
|
109
|
+
|
|
110
|
+
result = await task
|
|
111
|
+
assert "completed successfully" in result
|
|
112
|
+
# Coordination state is cleaned up after a successful handshake:
|
|
113
|
+
assert "call-1" not in pending_host_actions
|
|
114
|
+
assert "call-1" not in host_action_results
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_timeout_returns_message():
|
|
118
|
+
ctx = _Ctx("call-2", _Deps(_State()))
|
|
119
|
+
result = await slow_action(ctx) # nobody ever sets the event
|
|
120
|
+
assert "taking longer than expected" in result
|
|
121
|
+
assert "call-2" not in pending_host_actions
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_endpoint_sets_event_and_stores_result():
|
|
125
|
+
app = FastAPI()
|
|
126
|
+
setup_host_action_endpoint(app)
|
|
127
|
+
client = TestClient(app)
|
|
128
|
+
|
|
129
|
+
pending_host_actions["call-3"] = asyncio.Event()
|
|
130
|
+
resp = client.post(
|
|
131
|
+
"/host-action-result",
|
|
132
|
+
json={
|
|
133
|
+
"toolCallId": "call-3",
|
|
134
|
+
"toolName": "navigate_to_page",
|
|
135
|
+
"result": {"success": True},
|
|
136
|
+
"success": True,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
assert resp.status_code == 200
|
|
140
|
+
assert resp.json()["toolCallId"] == "call-3"
|
|
141
|
+
assert pending_host_actions["call-3"].is_set()
|
|
142
|
+
assert host_action_results["call-3"]["success"] is True
|
|
143
|
+
|
|
144
|
+
pending_host_actions.pop("call-3", None)
|
|
145
|
+
host_action_results.pop("call-3", None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_endpoint_rejects_missing_tool_call_id():
|
|
149
|
+
app = FastAPI()
|
|
150
|
+
setup_host_action_endpoint(app)
|
|
151
|
+
client = TestClient(app)
|
|
152
|
+
resp = client.post(
|
|
153
|
+
"/host-action-result",
|
|
154
|
+
json={"toolName": "x", "result": {}, "success": True}, # no toolCallId
|
|
155
|
+
)
|
|
156
|
+
assert resp.status_code == 200
|
|
157
|
+
assert resp.json().get("status") == "error"
|
|
158
|
+
# the None key must NOT have been written
|
|
159
|
+
from bsquare_host.host_actions import host_action_results
|
|
160
|
+
assert None not in host_action_results
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import bsquare_host
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_public_api_exports():
|
|
5
|
+
for name in (
|
|
6
|
+
"host_action_proxy",
|
|
7
|
+
"setup_host_action_endpoint",
|
|
8
|
+
"get_coordination_stats",
|
|
9
|
+
"mount_agent_app",
|
|
10
|
+
"AgentState",
|
|
11
|
+
):
|
|
12
|
+
assert hasattr(bsquare_host, name), f"missing export: {name}"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from bsquare_host.state import AgentState
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_agent_state_defaults_are_none():
|
|
5
|
+
s = AgentState()
|
|
6
|
+
assert s.pending_host_action is None
|
|
7
|
+
assert s.host_action_result is None
|
|
8
|
+
assert s.api_token is None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_agent_state_is_mutable():
|
|
12
|
+
s = AgentState()
|
|
13
|
+
s.pending_host_action = {"action": "navigate_to_page"}
|
|
14
|
+
assert s.pending_host_action["action"] == "navigate_to_page"
|