Skip to content

Commit eb715a2

Browse files
authored
Fix/markdown stream improvements, uvloop (#582)
* typesafe tests * lint * enhancements round one * markdown improvements * anyio/uvloop/streaming improvements*lots * lint * algorithm tweaks
1 parent ad316d2 commit eb715a2

29 files changed

+1298
-1732
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ dependencies = [
3939
"keyring>=24.3.1",
4040
"python-frontmatter>=1.1.0",
4141
"agent-client-protocol>=0.7.0",
42-
"tiktoken>=0.12.0"
42+
"tiktoken>=0.12.0",
43+
"uvloop>=0.22.1",
4344
]
4445

4546
[project.optional-dependencies]

src/fast_agent/acp/slash_commands.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,15 @@ async def _handle_status_system(self) -> str:
558558
if error:
559559
return error
560560

561-
system_prompt = agent.instruction if isinstance(agent, InstructionAwareAgent) else None
561+
agent_name = (
562+
agent.name if isinstance(agent, InstructionAwareAgent) else self.current_agent_name
563+
)
564+
565+
system_prompt = None
566+
if agent_name in self._session_instructions:
567+
system_prompt = self._session_instructions[agent_name]
568+
elif isinstance(agent, InstructionAwareAgent):
569+
system_prompt = agent.instruction
562570
if not system_prompt:
563571
return "\n".join(
564572
[
@@ -569,9 +577,6 @@ async def _handle_status_system(self) -> str:
569577
)
570578

571579
# Format the response
572-
agent_name = (
573-
agent.name if isinstance(agent, InstructionAwareAgent) else self.current_agent_name
574-
)
575580
lines = [
576581
heading,
577582
"",

src/fast_agent/cli/__main__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,21 @@
44

55
from fast_agent.cli.constants import GO_SPECIFIC_OPTIONS, KNOWN_SUBCOMMANDS
66
from fast_agent.cli.main import app
7+
from fast_agent.utils.async_utils import configure_uvloop, ensure_event_loop
78

89
# if the arguments would work with "go" we'll just route to it
910

1011

1112
def main():
1213
"""Main entry point that handles auto-routing to 'go' command."""
14+
requested_uvloop, enabled_uvloop = configure_uvloop()
15+
if requested_uvloop and not enabled_uvloop:
16+
print(
17+
"FAST_AGENT_UVLOOP is set but uvloop is unavailable; falling back to asyncio.",
18+
file=sys.stderr,
19+
)
1320
try:
14-
loop = asyncio.get_event_loop()
21+
loop = ensure_event_loop()
1522

1623
def _log_asyncio_exception(loop: asyncio.AbstractEventLoop, context: dict) -> None:
1724
import logging

src/fast_agent/cli/commands/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,7 @@ fast-agent serve --description "Interact with the {agent} workflow via MCP"
118118
# Use per-connection instances to isolate history between clients
119119
fast-agent serve --instance-scope=connection --transport=http
120120
```
121+
122+
### Environment toggles
123+
124+
- uvloop is enabled by default when installed (non-Windows); set `FAST_AGENT_DISABLE_UV_LOOP=1` to opt out.

src/fast_agent/cli/commands/auth.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
list_keyring_tokens,
1717
)
1818
from fast_agent.ui.console import console
19+
from fast_agent.utils.async_utils import run_sync
1920

2021
app = typer.Typer(help="Manage OAuth authentication state for MCP servers")
2122

@@ -395,9 +396,7 @@ async def _run_login():
395396
typer.echo(f"Login failed: {e}")
396397
return False
397398

398-
import asyncio
399-
400-
ok = asyncio.run(_run_login())
399+
ok = bool(run_sync(_run_login))
401400
if ok:
402401
from fast_agent.mcp.oauth_client import compute_server_identity
403402

src/fast_agent/cli/commands/go.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@
99

1010
import typer
1111

12-
from fast_agent import FastAgent
13-
from fast_agent.agents.llm_agent import LlmAgent
1412
from fast_agent.cli.commands.server_helpers import add_servers_to_config, generate_server_name
1513
from fast_agent.cli.commands.url_parser import generate_server_configs, parse_server_urls
1614
from fast_agent.constants import DEFAULT_AGENT_INSTRUCTION
17-
from fast_agent.ui.console_display import ConsoleDisplay
15+
from fast_agent.utils.async_utils import configure_uvloop, create_event_loop, ensure_event_loop
1816

1917
app = typer.Typer(
2018
help="Run an interactive agent directly from the command line without creating an agent.py file",
@@ -126,7 +124,10 @@ async def _run_agent(
126124
permissions_enabled: bool = True,
127125
) -> None:
128126
"""Async implementation to run an interactive agent."""
127+
from fast_agent import FastAgent
128+
from fast_agent.agents.llm_agent import LlmAgent
129129
from fast_agent.mcp.prompts.prompt_load import load_prompt
130+
from fast_agent.ui.console_display import ConsoleDisplay
130131

131132
# Create the FastAgent instance
132133

@@ -273,6 +274,7 @@ def run_async_agent(
273274
permissions_enabled: bool = True,
274275
):
275276
"""Run the async agent function with proper loop handling."""
277+
configure_uvloop()
276278
server_list = servers.split(",") if servers else None
277279

278280
# Parse URLs and generate server configurations if provided
@@ -346,19 +348,12 @@ def run_async_agent(
346348
continue
347349

348350
# Check if we're already in an event loop
349-
try:
350-
loop = asyncio.get_event_loop()
351-
if loop.is_running():
352-
# We're inside a running event loop, so we can't use asyncio.run
353-
# Instead, create a new loop
354-
loop = asyncio.new_event_loop()
355-
asyncio.set_event_loop(loop)
356-
_set_asyncio_exception_handler(loop)
357-
except RuntimeError:
358-
# No event loop exists, so we'll create one
359-
loop = asyncio.new_event_loop()
360-
asyncio.set_event_loop(loop)
361-
_set_asyncio_exception_handler(loop)
351+
loop = ensure_event_loop()
352+
if loop.is_running():
353+
# We're inside a running event loop, so we can't use asyncio.run
354+
# Instead, create a new loop
355+
loop = create_event_loop()
356+
_set_asyncio_exception_handler(loop)
362357

363358
try:
364359
loop.run_until_complete(

src/fast_agent/cli/main.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
"""Main CLI entry point for MCP Agent."""
22

3+
import importlib
4+
5+
import click
36
import typer
4-
from rich.table import Table
7+
import typer.main
8+
from typer.core import TyperGroup
59

6-
from fast_agent.cli.commands import acp, auth, check_config, go, quickstart, serve, setup
710
from fast_agent.cli.terminal import Application
811
from fast_agent.ui.console import console as shared_console
912

13+
LAZY_SUBCOMMANDS: dict[str, str] = {
14+
"go": "fast_agent.cli.commands.go:app",
15+
"serve": "fast_agent.cli.commands.serve:app",
16+
"acp": "fast_agent.cli.commands.acp:app",
17+
"setup": "fast_agent.cli.commands.setup:app",
18+
"check": "fast_agent.cli.commands.check_config:app",
19+
"auth": "fast_agent.cli.commands.auth:app",
20+
"quickstart": "fast_agent.cli.commands.quickstart:app",
21+
"bootstrap": "fast_agent.cli.commands.quickstart:app",
22+
}
23+
24+
25+
class LazyGroup(TyperGroup):
26+
lazy_subcommands: dict[str, str] = {}
27+
28+
def list_commands(self, ctx: click.Context) -> list[str]:
29+
return sorted(self.lazy_subcommands)
30+
31+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
32+
target = self.lazy_subcommands.get(cmd_name)
33+
if not target:
34+
return None
35+
module_path, app_name = target.split(":", 1)
36+
module = importlib.import_module(module_path)
37+
typer_app = getattr(module, app_name)
38+
command = typer.main.get_command(typer_app)
39+
command.name = cmd_name
40+
return command
41+
42+
1043
app = typer.Typer(
44+
cls=LazyGroup,
1145
help="Use `fast-agent go --help` for interactive shell arguments and options.",
1246
add_completion=False, # We'll add this later when we have more commands
1347
)
14-
15-
# Subcommands
16-
app.add_typer(go.app, name="go", help="Run an interactive agent directly from the command line")
17-
app.add_typer(serve.app, name="serve", help="Run FastAgent as an MCP server")
18-
app.add_typer(acp.app, name="acp", help="Run FastAgent as an ACP stdio server")
19-
app.add_typer(setup.app, name="setup", help="Set up a new agent project")
20-
app.add_typer(check_config.app, name="check", help="Show or diagnose fast-agent configuration")
21-
app.add_typer(auth.app, name="auth", help="Manage OAuth authentication for MCP servers")
22-
app.add_typer(quickstart.app, name="bootstrap", help="Create example applications")
23-
app.add_typer(quickstart.app, name="quickstart", help="Create example applications")
48+
LazyGroup.lazy_subcommands = LAZY_SUBCOMMANDS
2449

2550
# Shared application context
2651
application = Application()
@@ -32,6 +57,7 @@ def show_welcome() -> None:
3257
"""Show a welcome message with available commands, using new styling."""
3358
from importlib.metadata import version
3459

60+
from rich.table import Table
3561
from rich.text import Text
3662

3763
try:

src/fast_agent/context.py

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from __future__ import annotations
22

3-
import asyncio
4-
import concurrent.futures
53
import logging
64
import uuid
75
from os import PathLike
@@ -27,6 +25,7 @@
2725
from fast_agent.core.logging.transport import create_transport
2826
from fast_agent.mcp_server_registry import ServerRegistry
2927
from fast_agent.skills import SkillRegistry
28+
from fast_agent.utils.async_utils import run_sync
3029

3130
if TYPE_CHECKING:
3231
from fast_agent.acp.acp_context import ACPContext
@@ -253,22 +252,10 @@ def get_current_context() -> Context:
253252
"""
254253
global _global_context
255254
if _global_context is None:
256-
try:
257-
# Try to get the current event loop
258-
loop = asyncio.get_event_loop()
259-
if loop.is_running():
260-
# Create a new loop in a separate thread
261-
def run_async():
262-
new_loop = asyncio.new_event_loop()
263-
asyncio.set_event_loop(new_loop)
264-
return new_loop.run_until_complete(initialize_context())
265-
266-
with concurrent.futures.ThreadPoolExecutor() as pool:
267-
_global_context = pool.submit(run_async).result()
268-
else:
269-
_global_context = loop.run_until_complete(initialize_context())
270-
except RuntimeError:
271-
_global_context = asyncio.run(initialize_context())
255+
result = run_sync(initialize_context)
256+
if result is None:
257+
raise RuntimeError("Failed to initialize global context")
258+
_global_context = result
272259
return _global_context
273260

274261

src/fast_agent/core/executor/workflow_signal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@ async def wait_for_signal(self, signal, timeout_seconds=None):
143143
if timeout_seconds:
144144
print(f"(Timeout in {timeout_seconds} seconds)")
145145

146-
# Use asyncio.get_event_loop().run_in_executor to make input non-blocking
147-
loop = asyncio.get_event_loop()
146+
# Use asyncio.get_running_loop().run_in_executor to make input non-blocking
147+
loop = asyncio.get_running_loop()
148148
if timeout_seconds is not None:
149149
try:
150150
value = await asyncio.wait_for(

src/fast_agent/core/logging/logger.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ProgressListener,
2222
)
2323
from fast_agent.core.logging.transport import AsyncEventBus, EventTransport
24+
from fast_agent.utils.async_utils import ensure_event_loop
2425

2526

2627
class Logger:
@@ -34,19 +35,9 @@ def __init__(self, namespace: str) -> None:
3435
self.namespace = namespace
3536
self.event_bus = AsyncEventBus.get()
3637

37-
def _ensure_event_loop(self):
38-
"""Ensure we have an event loop we can use."""
39-
try:
40-
return asyncio.get_running_loop()
41-
except RuntimeError:
42-
# If no loop is running, create a new one
43-
loop = asyncio.new_event_loop()
44-
asyncio.set_event_loop(loop)
45-
return loop
46-
4738
def _emit_event(self, event: Event) -> None:
4839
"""Emit an event by running it in the event loop."""
49-
loop = self._ensure_event_loop()
40+
loop = ensure_event_loop()
5041
if loop.is_running():
5142
# If we're in a thread with a running loop, schedule the coroutine
5243
asyncio.create_task(self.event_bus.emit(event))

0 commit comments

Comments
 (0)