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 ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2025
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ __version__ = "0.1.3"
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()