Expose app runtime info across adapters
This commit is contained in:
@@ -7,6 +7,7 @@ from .events import (
|
||||
event_from_history_output,
|
||||
)
|
||||
from .models import (
|
||||
AppInfo,
|
||||
CallError,
|
||||
CallEvent,
|
||||
CallEventKind,
|
||||
@@ -28,6 +29,7 @@ from .utils import generate_good_names
|
||||
|
||||
__all__ = [
|
||||
"App",
|
||||
"AppInfo",
|
||||
"CallError",
|
||||
"CallEvent",
|
||||
"CallEventKind",
|
||||
|
||||
+19
-1
@@ -2,12 +2,14 @@ import contextlib
|
||||
import io
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime
|
||||
from time import monotonic
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from ipython_demo.utils import generate_good_names
|
||||
|
||||
from .models import CallOptions, CallResponse, ShellInfo
|
||||
from .models import AppInfo, CallOptions, CallResponse, ShellInfo
|
||||
from .shell import Shell
|
||||
|
||||
|
||||
@@ -15,6 +17,10 @@ class App:
|
||||
"""Coordinate named shells and expose frontend-shaped call responses."""
|
||||
|
||||
def __init__(self):
|
||||
self.app_id = uuid4().hex
|
||||
self.created_at = datetime.now(UTC).isoformat()
|
||||
self._started_at = monotonic()
|
||||
self._total_calls = 0
|
||||
self.shells: dict[str, Shell] = {}
|
||||
self.last_shell: str | None = None
|
||||
|
||||
@@ -62,6 +68,17 @@ class App:
|
||||
raise KeyError("No shell exists yet")
|
||||
return self.get_shell(self.last_shell)
|
||||
|
||||
def info(self) -> AppInfo:
|
||||
"""Return runtime metadata without initializing or inspecting shells."""
|
||||
return AppInfo(
|
||||
app_id=self.app_id,
|
||||
created_at=self.created_at,
|
||||
uptime_seconds=monotonic() - self._started_at,
|
||||
shell_count=len(self.shells),
|
||||
last_shell=self.last_shell,
|
||||
total_calls=self._total_calls,
|
||||
)
|
||||
|
||||
def get_shell_2(self, shell_name: Literal["last"] | str) -> ShellInfo:
|
||||
"""Return frontend-safe metadata for a single shell."""
|
||||
if shell_name == "last":
|
||||
@@ -80,6 +97,7 @@ class App:
|
||||
options = CallOptions()
|
||||
selected_name, shell = self._select_shell(shell_name)
|
||||
call_id = uuid4().hex
|
||||
self._total_calls += 1
|
||||
execution, events = shell.run_cell(code, call_id=call_id)
|
||||
return CallResponse(
|
||||
call_id=call_id,
|
||||
|
||||
@@ -116,3 +116,15 @@ class ShellInfo:
|
||||
created_at: str
|
||||
last_used_at: str | None
|
||||
capabilities: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppInfo:
|
||||
"""Small, transport-safe summary of one running application instance."""
|
||||
|
||||
app_id: str
|
||||
created_at: str
|
||||
uptime_seconds: float
|
||||
shell_count: int
|
||||
last_shell: str | None
|
||||
total_calls: int
|
||||
|
||||
+28
-9
@@ -1,20 +1,32 @@
|
||||
# FastMCP is not FastAPI: this module exposes MCP tools, not HTTP routes.
|
||||
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
import ipython_demo
|
||||
from ipython_demo.models import CallOptions, CallResponse, ShellInfo
|
||||
from ipython_demo.models import AppInfo, CallOptions, CallResponse, ShellInfo
|
||||
|
||||
app = FastMCP(name="IPython Demo")
|
||||
shell_app = ipython_demo.App()
|
||||
|
||||
|
||||
@app.tool
|
||||
@app.tool(title="Get app info")
|
||||
def get_app_info() -> AppInfo:
|
||||
"""Return metadata about this running IPython application."""
|
||||
return shell_app.info()
|
||||
|
||||
|
||||
@app.tool(title="Run code")
|
||||
def run_code(
|
||||
code: str,
|
||||
shell_name: Literal["last", "new"] | str = "last",
|
||||
shell_name: Annotated[
|
||||
Literal["last", "new"] | str,
|
||||
Field(
|
||||
description="The name of the shell to run the code in. Use 'new' to create a new shell, or 'last' to use the most recently selected shell."
|
||||
),
|
||||
] = "last",
|
||||
options: CallOptions | None = None,
|
||||
) -> CallResponse:
|
||||
"""Run code in a named shell and return a frontend-facing call response.
|
||||
@@ -24,14 +36,21 @@ def run_code(
|
||||
return shell_app.run_code(code, shell_name, options=options)
|
||||
|
||||
|
||||
@app.tool
|
||||
@app.tool(title="List shells")
|
||||
def list_shells() -> list[ShellInfo]:
|
||||
"""Return frontend-safe metadata for all known shells."""
|
||||
return shell_app.list_shells()
|
||||
|
||||
|
||||
@app.tool
|
||||
def get_shell(shell_name: Literal["last"] | str) -> ShellInfo:
|
||||
@app.tool(title="Get shell")
|
||||
def get_shell(
|
||||
shell_name: Annotated[
|
||||
Literal["last"] | str,
|
||||
Field(
|
||||
description="The name of the shell to retrieve. Use 'last' to get the most recently selected shell."
|
||||
),
|
||||
],
|
||||
) -> ShellInfo:
|
||||
"""Return frontend-safe metadata for a single shell."""
|
||||
return (
|
||||
shell_app.get_last_shell()
|
||||
@@ -40,13 +59,13 @@ def get_shell(shell_name: Literal["last"] | str) -> ShellInfo:
|
||||
)
|
||||
|
||||
|
||||
@app.tool
|
||||
@app.tool(title="Create shell")
|
||||
def create_shell() -> ShellInfo:
|
||||
"""Create a fresh named shell and return its metadata."""
|
||||
return shell_app.create_shell()
|
||||
|
||||
|
||||
@app.tool
|
||||
@app.tool(title="Get last shell")
|
||||
def get_last_shell() -> ShellInfo:
|
||||
"""
|
||||
Return metadata for the most recently selected shell.
|
||||
|
||||
@@ -10,6 +10,11 @@ app = FastAPI(title="IPython Demo", version="0.1.0")
|
||||
ShellApp = App() # global, scary
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
def get_info():
|
||||
return ShellApp.info()
|
||||
|
||||
|
||||
@app.get("/shells")
|
||||
def list_shells():
|
||||
shells = ShellApp.list_shells()
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import SecretStr
|
||||
|
||||
from .ipython_wrapper import (
|
||||
create_shell,
|
||||
get_app_info,
|
||||
get_last_shell,
|
||||
get_shell,
|
||||
list_shells,
|
||||
@@ -38,7 +39,14 @@ llm = ChatOpenAI(
|
||||
|
||||
agent = create_agent(
|
||||
llm,
|
||||
tools=[create_shell, get_last_shell, get_shell, list_shells, run_code],
|
||||
tools=[
|
||||
get_app_info,
|
||||
create_shell,
|
||||
get_last_shell,
|
||||
get_shell,
|
||||
list_shells,
|
||||
run_code,
|
||||
],
|
||||
checkpointer=None, # soon
|
||||
)
|
||||
|
||||
|
||||
@@ -3,11 +3,17 @@ from typing import Literal
|
||||
from langchain.tools import tool
|
||||
|
||||
from ipython_demo import App
|
||||
from ipython_demo.models import CallOptions, CallResponse, ShellInfo
|
||||
from ipython_demo.models import AppInfo, CallOptions, CallResponse, ShellInfo
|
||||
|
||||
app = App() # global, again
|
||||
|
||||
|
||||
@tool
|
||||
def get_app_info() -> AppInfo:
|
||||
"""Return metadata about this running IPython application."""
|
||||
return app.info()
|
||||
|
||||
|
||||
# should be similar to mcp/webapp.
|
||||
@tool
|
||||
def run_code(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ipython_demo import App, AppInfo
|
||||
from ipython_mcp.app import app as mcp_app
|
||||
from ipython_webapp.app import app as web_app
|
||||
from langchain_demo.ipython_wrapper import get_app_info
|
||||
|
||||
|
||||
class AppInfoTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_app_info_describes_runtime_and_updates_after_execution(self):
|
||||
app = App()
|
||||
|
||||
before = app.info()
|
||||
self.assertIsInstance(before, AppInfo)
|
||||
self.assertEqual(before.shell_count, 0)
|
||||
self.assertIsNone(before.last_shell)
|
||||
self.assertEqual(before.total_calls, 0)
|
||||
self.assertGreaterEqual(before.uptime_seconds, 0)
|
||||
|
||||
app.run_code("1 + 1", shell_name="info-test")
|
||||
after = app.info()
|
||||
|
||||
self.assertEqual(after.shell_count, 1)
|
||||
self.assertEqual(after.last_shell, "info-test")
|
||||
self.assertEqual(after.total_calls, 1)
|
||||
self.assertGreaterEqual(after.uptime_seconds, before.uptime_seconds)
|
||||
|
||||
def test_web_app_exposes_info(self):
|
||||
response = TestClient(web_app).get("/info")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsInstance(response.json()["shell_count"], int)
|
||||
|
||||
async def test_mcp_app_exposes_info_tool(self):
|
||||
tools = await mcp_app.list_tools()
|
||||
|
||||
self.assertIn("get_app_info", {tool.name for tool in tools})
|
||||
|
||||
def test_langchain_exposes_info_tool(self):
|
||||
result = get_app_info.invoke({})
|
||||
|
||||
self.assertIsInstance(result, AppInfo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,7 @@ class MCPAppTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(
|
||||
{tool.name for tool in tools},
|
||||
{
|
||||
"get_app_info",
|
||||
"run_code",
|
||||
"list_shells",
|
||||
"get_shell",
|
||||
|
||||
Reference in New Issue
Block a user