afm-core 0.1.0.dev1__py3-none-any.whl
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.
- afm/__init__.py +4 -0
- afm/cli.py +622 -0
- afm/exceptions.py +109 -0
- afm/interfaces/__init__.py +2 -0
- afm/interfaces/base.py +57 -0
- afm/interfaces/console_chat.py +179 -0
- afm/interfaces/console_chat.tcss +105 -0
- afm/interfaces/web_chat.py +296 -0
- afm/interfaces/webhook.py +455 -0
- afm/models.py +228 -0
- afm/parser.py +118 -0
- afm/resources/chat-ui.html +251 -0
- afm/runner.py +97 -0
- afm/schema_validator.py +116 -0
- afm/templates.py +283 -0
- afm/variables.py +253 -0
- afm_core-0.1.0.dev1.dist-info/METADATA +19 -0
- afm_core-0.1.0.dev1.dist-info/RECORD +20 -0
- afm_core-0.1.0.dev1.dist-info/WHEEL +4 -0
- afm_core-0.1.0.dev1.dist-info/entry_points.txt +3 -0
afm/__init__.py
ADDED
afm/cli.py
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
# Copyright (c) 2025
|
|
2
|
+
# Licensed under the Apache License, Version 2.0
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import uvicorn
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
|
|
17
|
+
# Version is defined here to avoid circular import
|
|
18
|
+
__cli_version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from .exceptions import AFMError
|
|
21
|
+
from .interfaces.base import get_http_path, get_interfaces
|
|
22
|
+
from .interfaces.console_chat import async_run_console_chat
|
|
23
|
+
from .interfaces.web_chat import create_webchat_router
|
|
24
|
+
from .interfaces.webhook import (
|
|
25
|
+
WebSubSubscriber,
|
|
26
|
+
create_webhook_router,
|
|
27
|
+
log_task_exception,
|
|
28
|
+
subscribe_with_retry,
|
|
29
|
+
)
|
|
30
|
+
from .models import (
|
|
31
|
+
ConsoleChatInterface,
|
|
32
|
+
WebChatInterface,
|
|
33
|
+
WebhookInterface,
|
|
34
|
+
)
|
|
35
|
+
from .parser import parse_afm_file
|
|
36
|
+
from .runner import AgentRunner, discover_runners, load_runner
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from .models import AFMRecord
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_unified_app(
|
|
45
|
+
agent: AgentRunner,
|
|
46
|
+
*,
|
|
47
|
+
webchat_interface: WebChatInterface | None = None,
|
|
48
|
+
webhook_interface: WebhookInterface | None = None,
|
|
49
|
+
cors_origins: list[str] | None = None,
|
|
50
|
+
startup_event: asyncio.Event | None = None,
|
|
51
|
+
host: str = "0.0.0.0",
|
|
52
|
+
port: int = 8000,
|
|
53
|
+
) -> FastAPI:
|
|
54
|
+
if webchat_interface is None and webhook_interface is None:
|
|
55
|
+
raise ValueError("At least one HTTP interface must be provided")
|
|
56
|
+
|
|
57
|
+
# Set up WebSub subscriber if configured
|
|
58
|
+
websub_subscriber: WebSubSubscriber | None = None
|
|
59
|
+
secret: str | None = None
|
|
60
|
+
|
|
61
|
+
if webhook_interface is not None:
|
|
62
|
+
subscription = webhook_interface.subscription
|
|
63
|
+
secret = subscription.secret
|
|
64
|
+
|
|
65
|
+
if subscription.hub and subscription.topic:
|
|
66
|
+
webhook_path = get_http_path(webhook_interface)
|
|
67
|
+
|
|
68
|
+
# Determine callback URL - use localhost for 0.0.0.0 since it's not externally routable
|
|
69
|
+
if subscription.callback:
|
|
70
|
+
callback_url = subscription.callback
|
|
71
|
+
else:
|
|
72
|
+
# Construct fallback callback URL from host/port
|
|
73
|
+
# Use localhost for 0.0.0.0 since it's not externally routable
|
|
74
|
+
if host == "0.0.0.0":
|
|
75
|
+
effective_host = "localhost"
|
|
76
|
+
else:
|
|
77
|
+
effective_host = host
|
|
78
|
+
callback_url = f"http://{effective_host}:{port}{webhook_path}"
|
|
79
|
+
logger.warning(
|
|
80
|
+
f"Using auto-generated WebSub callback URL: {callback_url}. "
|
|
81
|
+
"For production use, set subscription.callback explicitly in the AFM file."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
websub_subscriber = WebSubSubscriber(
|
|
85
|
+
hub=subscription.hub,
|
|
86
|
+
topic=subscription.topic,
|
|
87
|
+
callback=callback_url,
|
|
88
|
+
secret=secret,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Create lifespan for MCP connection management and WebSub
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
94
|
+
# Connect MCP servers on startup
|
|
95
|
+
await agent.connect()
|
|
96
|
+
|
|
97
|
+
# Startup: Subscribe to WebSub hub
|
|
98
|
+
if websub_subscriber:
|
|
99
|
+
# Run subscription in background
|
|
100
|
+
subscription_task = asyncio.create_task(
|
|
101
|
+
subscribe_with_retry(websub_subscriber)
|
|
102
|
+
)
|
|
103
|
+
subscription_task.add_done_callback(log_task_exception)
|
|
104
|
+
app.state.subscription_task = subscription_task
|
|
105
|
+
|
|
106
|
+
# Signal that startup is complete if an event was provided
|
|
107
|
+
if startup_event is not None:
|
|
108
|
+
startup_event.set()
|
|
109
|
+
yield
|
|
110
|
+
# Shutdown: Cancel pending subscription task
|
|
111
|
+
subscription_task = getattr(app.state, "subscription_task", None)
|
|
112
|
+
if subscription_task is not None and not subscription_task.done():
|
|
113
|
+
subscription_task.cancel()
|
|
114
|
+
try:
|
|
115
|
+
await subscription_task
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
pass
|
|
118
|
+
# Unsubscribe from WebSub hub if verified
|
|
119
|
+
if websub_subscriber and websub_subscriber.is_verified:
|
|
120
|
+
await websub_subscriber.unsubscribe()
|
|
121
|
+
|
|
122
|
+
# Disconnect MCP servers on shutdown
|
|
123
|
+
await agent.disconnect()
|
|
124
|
+
|
|
125
|
+
# Create main app
|
|
126
|
+
app = FastAPI(
|
|
127
|
+
title=agent.name,
|
|
128
|
+
description=agent.description or f"AFM Agent: {agent.name}",
|
|
129
|
+
version=agent.afm.metadata.version or "0.0.0",
|
|
130
|
+
lifespan=lifespan,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Add CORS middleware if origins specified
|
|
134
|
+
if cors_origins:
|
|
135
|
+
app.add_middleware(
|
|
136
|
+
CORSMiddleware,
|
|
137
|
+
allow_origins=cors_origins,
|
|
138
|
+
allow_credentials=True,
|
|
139
|
+
allow_methods=["*"],
|
|
140
|
+
allow_headers=["*"],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Store agent reference
|
|
144
|
+
app.state.agent = agent
|
|
145
|
+
|
|
146
|
+
# Determine paths for info endpoint
|
|
147
|
+
webchat_path = get_http_path(webchat_interface) if webchat_interface else None
|
|
148
|
+
webhook_path = get_http_path(webhook_interface) if webhook_interface else None
|
|
149
|
+
|
|
150
|
+
@app.get("/")
|
|
151
|
+
async def root_info() -> dict[str, Any]:
|
|
152
|
+
"""Get agent metadata."""
|
|
153
|
+
return {
|
|
154
|
+
"name": agent.name,
|
|
155
|
+
"description": agent.description,
|
|
156
|
+
"version": agent.afm.metadata.version,
|
|
157
|
+
"interfaces": {
|
|
158
|
+
"webchat": webchat_path,
|
|
159
|
+
"webhook": webhook_path,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@app.get("/health")
|
|
164
|
+
async def health_check() -> dict[str, str]:
|
|
165
|
+
"""Health check endpoint."""
|
|
166
|
+
return {"status": "ok"}
|
|
167
|
+
|
|
168
|
+
if webchat_interface is not None:
|
|
169
|
+
webchat_router = create_webchat_router(
|
|
170
|
+
agent,
|
|
171
|
+
webchat_interface.signature,
|
|
172
|
+
webchat_path, # type: ignore[arg-type]
|
|
173
|
+
)
|
|
174
|
+
app.include_router(webchat_router)
|
|
175
|
+
|
|
176
|
+
if webhook_interface is not None:
|
|
177
|
+
webhook_router = create_webhook_router(
|
|
178
|
+
agent,
|
|
179
|
+
webhook_interface,
|
|
180
|
+
webhook_path, # type: ignore[arg-type]
|
|
181
|
+
)
|
|
182
|
+
app.include_router(webhook_router)
|
|
183
|
+
# Store subscriber in app state for verification endpoint
|
|
184
|
+
app.state.websub_subscriber = websub_subscriber
|
|
185
|
+
app.state.secret = secret
|
|
186
|
+
|
|
187
|
+
return app
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def format_validation_output(afm: AFMRecord) -> str:
|
|
191
|
+
lines: list[str] = []
|
|
192
|
+
lines.append("")
|
|
193
|
+
lines.append("Agent validated successfully")
|
|
194
|
+
lines.append("")
|
|
195
|
+
|
|
196
|
+
# Basic info
|
|
197
|
+
name = afm.metadata.name or "Unnamed Agent"
|
|
198
|
+
lines.append(f" Name: {name}")
|
|
199
|
+
|
|
200
|
+
if afm.metadata.description:
|
|
201
|
+
lines.append(f" Description: {afm.metadata.description}")
|
|
202
|
+
|
|
203
|
+
if afm.metadata.version:
|
|
204
|
+
lines.append(f" Version: {afm.metadata.version}")
|
|
205
|
+
|
|
206
|
+
# Model info
|
|
207
|
+
if afm.metadata.model:
|
|
208
|
+
model = afm.metadata.model
|
|
209
|
+
model_name = model.name or "default"
|
|
210
|
+
provider = model.provider or "openai"
|
|
211
|
+
lines.append(f" Model: {model_name} via {provider}")
|
|
212
|
+
|
|
213
|
+
lines.append("")
|
|
214
|
+
|
|
215
|
+
# Interfaces
|
|
216
|
+
interfaces = get_interfaces(afm)
|
|
217
|
+
lines.append(" Interfaces:")
|
|
218
|
+
for iface in interfaces:
|
|
219
|
+
sig = iface.signature
|
|
220
|
+
sig_str = f"{sig.input.type} -> {sig.output.type}"
|
|
221
|
+
|
|
222
|
+
if isinstance(iface, ConsoleChatInterface):
|
|
223
|
+
lines.append(f" - consolechat ({sig_str})")
|
|
224
|
+
elif isinstance(iface, WebChatInterface):
|
|
225
|
+
path = get_http_path(iface)
|
|
226
|
+
lines.append(f" - webchat at {path} ({sig_str})")
|
|
227
|
+
elif isinstance(iface, WebhookInterface):
|
|
228
|
+
path = get_http_path(iface)
|
|
229
|
+
lines.append(f" - webhook at {path} ({sig_str})")
|
|
230
|
+
|
|
231
|
+
# Tools
|
|
232
|
+
if afm.metadata.tools and afm.metadata.tools.mcp:
|
|
233
|
+
lines.append("")
|
|
234
|
+
lines.append(" MCP Servers:")
|
|
235
|
+
for server in afm.metadata.tools.mcp:
|
|
236
|
+
lines.append(f" - {server.name}: {server.transport.url}")
|
|
237
|
+
if server.tool_filter:
|
|
238
|
+
if server.tool_filter.allow:
|
|
239
|
+
lines.append(f" Allow: {', '.join(server.tool_filter.allow)}")
|
|
240
|
+
if server.tool_filter.deny:
|
|
241
|
+
lines.append(f" Deny: {', '.join(server.tool_filter.deny)}")
|
|
242
|
+
|
|
243
|
+
lines.append("")
|
|
244
|
+
return "\n".join(lines)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def extract_interfaces(
|
|
248
|
+
afm: AFMRecord,
|
|
249
|
+
) -> tuple[
|
|
250
|
+
ConsoleChatInterface | None, WebChatInterface | None, WebhookInterface | None
|
|
251
|
+
]:
|
|
252
|
+
interfaces = get_interfaces(afm)
|
|
253
|
+
|
|
254
|
+
consolechat: ConsoleChatInterface | None = None
|
|
255
|
+
webchat: WebChatInterface | None = None
|
|
256
|
+
webhook: WebhookInterface | None = None
|
|
257
|
+
|
|
258
|
+
console_count = 0
|
|
259
|
+
webchat_count = 0
|
|
260
|
+
webhook_count = 0
|
|
261
|
+
|
|
262
|
+
for iface in interfaces:
|
|
263
|
+
if isinstance(iface, ConsoleChatInterface):
|
|
264
|
+
console_count += 1
|
|
265
|
+
consolechat = iface
|
|
266
|
+
elif isinstance(iface, WebChatInterface):
|
|
267
|
+
webchat_count += 1
|
|
268
|
+
webchat = iface
|
|
269
|
+
elif isinstance(iface, WebhookInterface):
|
|
270
|
+
webhook_count += 1
|
|
271
|
+
webhook = iface
|
|
272
|
+
|
|
273
|
+
if console_count > 1 or webchat_count > 1 or webhook_count > 1:
|
|
274
|
+
raise click.ClickException(
|
|
275
|
+
"Multiple interfaces of the same type are not supported"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return consolechat, webchat, webhook
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# CLI entry point: Click Group with subcommands
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@click.group()
|
|
287
|
+
@click.version_option(version=__cli_version__, prog_name="afm")
|
|
288
|
+
def cli() -> None:
|
|
289
|
+
"""AFM Agent CLI — parse, validate, and run Agent-Flavored Markdown files."""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@cli.command()
|
|
293
|
+
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
294
|
+
def validate(file: Path) -> None:
|
|
295
|
+
"""Validate an AFM file without running the agent.
|
|
296
|
+
|
|
297
|
+
Parses FILE and displays agent metadata, interfaces, and tools.
|
|
298
|
+
Does NOT require a backend (e.g. langchain) to be installed.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
afm = parse_afm_file(str(file))
|
|
302
|
+
except AFMError as e:
|
|
303
|
+
raise click.ClickException(f"Failed to parse AFM file: {e}") from e
|
|
304
|
+
except Exception as e:
|
|
305
|
+
raise click.ClickException(f"Unexpected error parsing AFM file: {e}") from e
|
|
306
|
+
|
|
307
|
+
click.echo(f"Loading: {file}")
|
|
308
|
+
click.echo(format_validation_output(afm))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cli.command()
|
|
312
|
+
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
313
|
+
@click.option(
|
|
314
|
+
"--framework",
|
|
315
|
+
"-f",
|
|
316
|
+
default=None,
|
|
317
|
+
help="Runner backend to use (e.g. 'langchain'). Auto-detected if omitted.",
|
|
318
|
+
)
|
|
319
|
+
@click.option(
|
|
320
|
+
"--port",
|
|
321
|
+
"-p",
|
|
322
|
+
default=8000,
|
|
323
|
+
type=int,
|
|
324
|
+
help="HTTP port for web interfaces (default: 8000)",
|
|
325
|
+
)
|
|
326
|
+
@click.option(
|
|
327
|
+
"--host",
|
|
328
|
+
"-H",
|
|
329
|
+
default="0.0.0.0",
|
|
330
|
+
help="Host to bind HTTP server to (default: 0.0.0.0)",
|
|
331
|
+
)
|
|
332
|
+
@click.option(
|
|
333
|
+
"--dry-run",
|
|
334
|
+
is_flag=True,
|
|
335
|
+
help="Validate AFM file without running the agent",
|
|
336
|
+
)
|
|
337
|
+
@click.option(
|
|
338
|
+
"--no-console",
|
|
339
|
+
is_flag=True,
|
|
340
|
+
help="Skip consolechat interface even if defined",
|
|
341
|
+
)
|
|
342
|
+
@click.option(
|
|
343
|
+
"--verbose",
|
|
344
|
+
"-v",
|
|
345
|
+
is_flag=True,
|
|
346
|
+
help="Enable verbose/debug logging",
|
|
347
|
+
)
|
|
348
|
+
@click.option(
|
|
349
|
+
"--log-file",
|
|
350
|
+
"-l",
|
|
351
|
+
type=click.Path(path_type=Path),
|
|
352
|
+
help="Redirect logs to a file",
|
|
353
|
+
)
|
|
354
|
+
def run(
|
|
355
|
+
file: Path,
|
|
356
|
+
framework: str | None,
|
|
357
|
+
port: int,
|
|
358
|
+
host: str,
|
|
359
|
+
dry_run: bool,
|
|
360
|
+
no_console: bool,
|
|
361
|
+
verbose: bool,
|
|
362
|
+
log_file: Path | None,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Run an AFM agent from FILE.
|
|
365
|
+
|
|
366
|
+
The agent will start all interfaces defined in the AFM file.
|
|
367
|
+
HTTP interfaces (webchat, webhook) run on the specified port.
|
|
368
|
+
Console chat runs interactively in the terminal.
|
|
369
|
+
"""
|
|
370
|
+
try:
|
|
371
|
+
afm = parse_afm_file(str(file))
|
|
372
|
+
except AFMError as e:
|
|
373
|
+
raise click.ClickException(f"Failed to parse AFM file: {e}") from e
|
|
374
|
+
except Exception as e:
|
|
375
|
+
raise click.ClickException(f"Unexpected error parsing AFM file: {e}") from e
|
|
376
|
+
|
|
377
|
+
# Extract interfaces
|
|
378
|
+
consolechat, webchat, webhook = extract_interfaces(afm)
|
|
379
|
+
|
|
380
|
+
# Check if we have anything to run
|
|
381
|
+
has_http = webchat is not None or webhook is not None
|
|
382
|
+
has_console = (consolechat is not None or not has_http) and not no_console
|
|
383
|
+
|
|
384
|
+
# Configure logging
|
|
385
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
386
|
+
log_handlers: list[logging.Handler] = []
|
|
387
|
+
|
|
388
|
+
if has_console:
|
|
389
|
+
# Scenario: Console Chat is active
|
|
390
|
+
if log_file:
|
|
391
|
+
# Route all logs to the specified file, silence terminal
|
|
392
|
+
log_handlers.append(logging.FileHandler(log_file))
|
|
393
|
+
else:
|
|
394
|
+
# Silence all logs (no handlers)
|
|
395
|
+
log_handlers.append(logging.NullHandler())
|
|
396
|
+
else:
|
|
397
|
+
# Scenario: Non-Console mode
|
|
398
|
+
log_handlers.append(logging.StreamHandler())
|
|
399
|
+
if log_file:
|
|
400
|
+
# Route logs to file AND terminal
|
|
401
|
+
log_handlers.append(logging.FileHandler(log_file))
|
|
402
|
+
|
|
403
|
+
logging.basicConfig(
|
|
404
|
+
level=log_level,
|
|
405
|
+
format="%(levelname)s: %(message)s",
|
|
406
|
+
handlers=log_handlers,
|
|
407
|
+
force=True, # Override any existing configuration
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if not verbose:
|
|
411
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
412
|
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
413
|
+
|
|
414
|
+
click.echo(f"Loading: {file}")
|
|
415
|
+
|
|
416
|
+
# Dry-run mode: validate and exit
|
|
417
|
+
if dry_run:
|
|
418
|
+
click.echo(format_validation_output(afm))
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if not has_http and not has_console:
|
|
422
|
+
click.echo("No interfaces to run (consolechat skipped with --no-console)")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
# Load runner backend via entry points
|
|
426
|
+
try:
|
|
427
|
+
runner_cls = load_runner(framework)
|
|
428
|
+
except RuntimeError as e:
|
|
429
|
+
raise click.ClickException(str(e)) from e
|
|
430
|
+
|
|
431
|
+
# Create agent
|
|
432
|
+
agent = runner_cls(afm)
|
|
433
|
+
|
|
434
|
+
# Print startup info
|
|
435
|
+
click.echo("")
|
|
436
|
+
click.echo(f"Agent: {agent.name}")
|
|
437
|
+
if agent.description:
|
|
438
|
+
click.echo(f"Description: {agent.description}")
|
|
439
|
+
|
|
440
|
+
click.echo("")
|
|
441
|
+
click.echo("Starting interfaces:")
|
|
442
|
+
|
|
443
|
+
if webchat:
|
|
444
|
+
webchat_path = get_http_path(webchat)
|
|
445
|
+
click.echo(f" - webchat at http://{host}:{port}{webchat_path}")
|
|
446
|
+
|
|
447
|
+
if has_console:
|
|
448
|
+
click.echo(" - consolechat (interactive)")
|
|
449
|
+
|
|
450
|
+
click.echo("")
|
|
451
|
+
|
|
452
|
+
# Run the appropriate configuration
|
|
453
|
+
if has_http and has_console:
|
|
454
|
+
# Both HTTP and console: run HTTP in background, console in foreground
|
|
455
|
+
asyncio.run(
|
|
456
|
+
_run_http_and_console(
|
|
457
|
+
agent, webchat, webhook, host, port, verbose, has_console, log_file
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
elif has_http:
|
|
461
|
+
# HTTP only: run uvicorn blocking
|
|
462
|
+
_run_http_only(agent, webchat, webhook, host, port, verbose, log_file)
|
|
463
|
+
else:
|
|
464
|
+
# Console only: run console blocking
|
|
465
|
+
asyncio.run(_run_console_only(agent))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@cli.group()
|
|
469
|
+
def framework() -> None:
|
|
470
|
+
"""Manage runner frameworks (backends)."""
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@framework.command(name="list")
|
|
474
|
+
def framework_list() -> None:
|
|
475
|
+
"""List discovered runner backends."""
|
|
476
|
+
runners = discover_runners()
|
|
477
|
+
|
|
478
|
+
if not runners:
|
|
479
|
+
click.echo("No runner backends found.")
|
|
480
|
+
click.echo("")
|
|
481
|
+
click.echo("Install a backend package such as 'afm-langchain':")
|
|
482
|
+
click.echo(" uv add afm-langchain")
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
click.echo("Discovered runner backends:")
|
|
486
|
+
click.echo("")
|
|
487
|
+
for name, ep in runners.items():
|
|
488
|
+
click.echo(f" - {name} ({ep.value})")
|
|
489
|
+
click.echo("")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
# ---------------------------------------------------------------------------
|
|
493
|
+
# Internal helpers
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def _run_http_and_console(
|
|
498
|
+
agent: AgentRunner,
|
|
499
|
+
webchat: WebChatInterface | None,
|
|
500
|
+
webhook: WebhookInterface | None,
|
|
501
|
+
host: str,
|
|
502
|
+
port: int,
|
|
503
|
+
verbose: bool,
|
|
504
|
+
has_console: bool = False,
|
|
505
|
+
log_file: Path | None = None,
|
|
506
|
+
) -> None:
|
|
507
|
+
# Event to signal when server startup is complete and agent is connected
|
|
508
|
+
startup_event = asyncio.Event()
|
|
509
|
+
|
|
510
|
+
# Create unified app (lifespan handles MCP connection)
|
|
511
|
+
app = create_unified_app(
|
|
512
|
+
agent,
|
|
513
|
+
webchat_interface=webchat,
|
|
514
|
+
webhook_interface=webhook,
|
|
515
|
+
startup_event=startup_event,
|
|
516
|
+
host=host,
|
|
517
|
+
port=port,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Configure uvicorn logging level
|
|
521
|
+
# If console is active and no log file, silence uvicorn by setting to warning/error
|
|
522
|
+
if has_console and not log_file:
|
|
523
|
+
uvicorn_log_level = "warning"
|
|
524
|
+
else:
|
|
525
|
+
uvicorn_log_level = "debug" if verbose else "info"
|
|
526
|
+
|
|
527
|
+
# Configure uvicorn
|
|
528
|
+
config = uvicorn.Config(
|
|
529
|
+
app,
|
|
530
|
+
host=host,
|
|
531
|
+
port=port,
|
|
532
|
+
log_level=uvicorn_log_level,
|
|
533
|
+
)
|
|
534
|
+
server = uvicorn.Server(config)
|
|
535
|
+
|
|
536
|
+
# Start HTTP server in background task
|
|
537
|
+
server_task = asyncio.create_task(server.serve())
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
# Wait for EITHER server startup to complete OR server to fail.
|
|
541
|
+
startup_waiter = asyncio.create_task(startup_event.wait())
|
|
542
|
+
done, _pending = await asyncio.wait(
|
|
543
|
+
[server_task, startup_waiter],
|
|
544
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if server_task in done:
|
|
548
|
+
# Server exited before startup completed — propagate the error
|
|
549
|
+
startup_waiter.cancel()
|
|
550
|
+
server_task.result() # raises the underlying exception
|
|
551
|
+
return # unreachable if result() raised
|
|
552
|
+
|
|
553
|
+
# Server started successfully — run console chat, but also watch
|
|
554
|
+
# for the server dying mid-run so we don't hang.
|
|
555
|
+
console_task = asyncio.create_task(async_run_console_chat(agent))
|
|
556
|
+
done, pending = await asyncio.wait(
|
|
557
|
+
[server_task, console_task],
|
|
558
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
for task in pending:
|
|
562
|
+
task.cancel()
|
|
563
|
+
try:
|
|
564
|
+
await task
|
|
565
|
+
except (asyncio.CancelledError, SystemExit):
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
# If the server exited (e.g. port in use), inform the user
|
|
569
|
+
if server_task in done:
|
|
570
|
+
click.echo(
|
|
571
|
+
"\nHTTP server stopped unexpectedly. Exiting.",
|
|
572
|
+
err=True,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Propagate any real exception from whichever task finished first
|
|
576
|
+
for task in done:
|
|
577
|
+
exc = task.exception()
|
|
578
|
+
if exc is not None and not isinstance(exc, SystemExit):
|
|
579
|
+
raise exc
|
|
580
|
+
finally:
|
|
581
|
+
# Ensure the HTTP server shuts down cleanly
|
|
582
|
+
server.should_exit = True
|
|
583
|
+
try:
|
|
584
|
+
await server_task
|
|
585
|
+
except (asyncio.CancelledError, SystemExit):
|
|
586
|
+
pass
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _run_http_only(
|
|
590
|
+
agent: AgentRunner,
|
|
591
|
+
webchat: WebChatInterface | None,
|
|
592
|
+
webhook: WebhookInterface | None,
|
|
593
|
+
host: str,
|
|
594
|
+
port: int,
|
|
595
|
+
verbose: bool,
|
|
596
|
+
log_file: Path | None = None,
|
|
597
|
+
) -> None:
|
|
598
|
+
# Create unified app (lifespan handles MCP connections)
|
|
599
|
+
app = create_unified_app(
|
|
600
|
+
agent,
|
|
601
|
+
webchat_interface=webchat,
|
|
602
|
+
webhook_interface=webhook,
|
|
603
|
+
host=host,
|
|
604
|
+
port=port,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# Run uvicorn (blocking)
|
|
608
|
+
uvicorn.run(
|
|
609
|
+
app,
|
|
610
|
+
host=host,
|
|
611
|
+
port=port,
|
|
612
|
+
log_level="debug" if verbose else "info",
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def _run_console_only(agent: AgentRunner) -> None:
|
|
617
|
+
async with agent:
|
|
618
|
+
await async_run_console_chat(agent)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
if __name__ == "__main__":
|
|
622
|
+
cli()
|