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/exceptions.py ADDED
@@ -0,0 +1,109 @@
1
+ # Copyright (c) 2025
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+
5
+ class AFMError(Exception):
6
+ pass
7
+
8
+
9
+ class AFMParseError(AFMError):
10
+ def __init__(self, message: str, line: int | None = None):
11
+ self.line = line
12
+ if line is not None:
13
+ message = f"Line {line}: {message}"
14
+ super().__init__(message)
15
+
16
+
17
+ class AFMValidationError(AFMError):
18
+ def __init__(self, message: str, field: str | None = None):
19
+ self.field = field
20
+ if field is not None:
21
+ message = f"Field '{field}': {message}"
22
+ super().__init__(message)
23
+
24
+
25
+ class VariableResolutionError(AFMError):
26
+ def __init__(self, variable: str, reason: str):
27
+ self.variable = variable
28
+ self.reason = reason
29
+ super().__init__(f"Cannot resolve variable '${{{variable}}}': {reason}")
30
+
31
+
32
+ class TemplateError(AFMError):
33
+ def __init__(self, message: str, template: str | None = None):
34
+ self.template = template
35
+ super().__init__(message)
36
+
37
+
38
+ class TemplateCompilationError(TemplateError):
39
+ pass
40
+
41
+
42
+ class TemplateEvaluationError(TemplateError):
43
+ pass
44
+
45
+
46
+ class JSONAccessError(AFMError):
47
+ def __init__(self, message: str, path: str | None = None):
48
+ self.path = path
49
+ super().__init__(message)
50
+
51
+
52
+ class AgentError(AFMError):
53
+ pass
54
+
55
+
56
+ class ProviderError(AgentError):
57
+ def __init__(self, message: str, provider: str | None = None):
58
+ self.provider = provider
59
+ if provider is not None:
60
+ message = f"Provider '{provider}': {message}"
61
+ super().__init__(message)
62
+
63
+
64
+ class InputValidationError(AFMValidationError):
65
+ def __init__(self, message: str, schema_path: str | None = None):
66
+ self.schema_path = schema_path
67
+ super().__init__(message, field="input")
68
+
69
+
70
+ class OutputValidationError(AFMValidationError):
71
+ def __init__(self, message: str, schema_path: str | None = None):
72
+ self.schema_path = schema_path
73
+ super().__init__(message, field="output")
74
+
75
+
76
+ class InterfaceNotFoundError(AFMError):
77
+ def __init__(self, interface_type: str, available: list[str]) -> None:
78
+ self.interface_type = interface_type
79
+ self.available = available
80
+ super().__init__(
81
+ f"Interface type '{interface_type}' not found. "
82
+ f"Available: {available if available else ['consolechat (default)']}"
83
+ )
84
+
85
+
86
+ class MCPError(AFMError):
87
+ def __init__(self, message: str, server_name: str | None = None):
88
+ self.server_name = server_name
89
+ if server_name is not None:
90
+ message = f"MCP server '{server_name}': {message}"
91
+ super().__init__(message)
92
+
93
+
94
+ class MCPConnectionError(MCPError):
95
+ pass
96
+
97
+
98
+ class MCPToolError(MCPError):
99
+ def __init__(
100
+ self, message: str, server_name: str | None = None, tool_name: str | None = None
101
+ ):
102
+ self.tool_name = tool_name
103
+ if tool_name is not None:
104
+ message = f"Tool '{tool_name}': {message}"
105
+ super().__init__(message, server_name)
106
+
107
+
108
+ class MCPAuthenticationError(MCPError):
109
+ pass
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2025
2
+ # Licensed under the Apache License, Version 2.0
afm/interfaces/base.py ADDED
@@ -0,0 +1,57 @@
1
+ # Copyright (c) 2025
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from ..exceptions import InterfaceNotFoundError
7
+ from ..models import (
8
+ AFMRecord,
9
+ ConsoleChatInterface,
10
+ Interface,
11
+ InterfaceType,
12
+ WebChatInterface,
13
+ WebhookInterface,
14
+ )
15
+
16
+
17
+ def get_interfaces(afm: AFMRecord) -> list[Interface]:
18
+ if afm.metadata.interfaces:
19
+ return list(afm.metadata.interfaces)
20
+ # Default to consolechat if no interfaces specified
21
+ return [ConsoleChatInterface()]
22
+
23
+
24
+ def get_interface_by_type(
25
+ afm: AFMRecord,
26
+ interface_type: InterfaceType,
27
+ ) -> Interface:
28
+ interfaces = get_interfaces(afm)
29
+
30
+ for interface in interfaces:
31
+ if interface.type == interface_type.value:
32
+ return interface
33
+
34
+ available = [iface.type for iface in interfaces]
35
+ raise InterfaceNotFoundError(interface_type.value, available)
36
+
37
+
38
+ def get_webchat_interface(afm: AFMRecord) -> WebChatInterface:
39
+ interface = get_interface_by_type(afm, InterfaceType.WEB_CHAT)
40
+ assert isinstance(interface, WebChatInterface)
41
+ return interface
42
+
43
+
44
+ def get_webhook_interface(afm: AFMRecord) -> WebhookInterface:
45
+ interface = get_interface_by_type(afm, InterfaceType.WEBHOOK)
46
+ assert isinstance(interface, WebhookInterface)
47
+ return interface
48
+
49
+
50
+ def get_http_path(interface: WebChatInterface | WebhookInterface) -> str:
51
+ if interface.exposure and interface.exposure.http:
52
+ return interface.exposure.http.path
53
+
54
+ # Defaults per spec
55
+ if isinstance(interface, WebChatInterface):
56
+ return "/chat"
57
+ return "/webhook"
@@ -0,0 +1,179 @@
1
+ # Copyright (c) 2025
2
+ # Licensed under the Apache License, Version 2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import uuid
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich.markup import escape
11
+ from textual import work
12
+ from textual.app import App, ComposeResult
13
+ from textual.containers import Vertical, VerticalScroll
14
+ from textual.widgets import Footer, Header, Input, LoadingIndicator, Static
15
+
16
+ if TYPE_CHECKING:
17
+ from ..runner import AgentRunner
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ChatApp(App):
23
+ CSS_PATH = "console_chat.tcss"
24
+ BINDINGS = [
25
+ ("ctrl+q", "quit", "Quit"),
26
+ ("ctrl+l", "clear_history", "Clear History"),
27
+ ("ctrl+h", "show_help", "Help"),
28
+ ]
29
+
30
+ def __init__(
31
+ self,
32
+ agent: AgentRunner,
33
+ session_id: str | None = None,
34
+ ):
35
+ super().__init__()
36
+ self.agent = agent
37
+ self.session_id = session_id or str(uuid.uuid4())
38
+
39
+ def compose(self) -> ComposeResult:
40
+ yield Header()
41
+ yield VerticalScroll(id="chat-log")
42
+ yield Input(placeholder="Type a message...", id="chat-input")
43
+ yield Footer()
44
+
45
+ def on_mount(self) -> None:
46
+ logger.debug("ChatApp.on_mount called")
47
+ self.title = f"Chat with {self.agent.name}"
48
+ if self.agent.description:
49
+ self.sub_title = self.agent.description
50
+
51
+ # Show welcome message
52
+ welcome_msg = (
53
+ f"Welcome to chat with {self.agent.name}!\n"
54
+ "type 'exit', 'quit' or Ctrl+Q to end.\n"
55
+ "type 'help' or Ctrl+H for help.\n"
56
+ "type 'clear' or Ctrl+L to clear history."
57
+ )
58
+ self.query_one("#chat-log").mount(Static(welcome_msg, classes="system-message"))
59
+ self.query_one("#chat-input").focus()
60
+
61
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
62
+ logger.debug(f"on_input_submitted triggered. Value: '{event.value}'")
63
+ try:
64
+ user_input = event.value.strip()
65
+ logger.debug(f"Input submitted: {user_input}")
66
+ if not user_input:
67
+ return
68
+
69
+ # Clear input
70
+ event.input.value = ""
71
+
72
+ # Handle local commands
73
+ command = user_input.lower()
74
+ if command in ("exit", "quit"):
75
+ self.exit()
76
+ return
77
+
78
+ if command == "help":
79
+ self.action_show_help()
80
+ return
81
+
82
+ if command == "clear":
83
+ self.action_clear_history()
84
+ return
85
+
86
+ # Display user message
87
+ chat_log = self.query_one("#chat-log")
88
+ msg_widget = Static(f"{escape(user_input)}", classes="message user-message")
89
+ await chat_log.mount(
90
+ Vertical(
91
+ msg_widget, classes="message-container message-container--user"
92
+ )
93
+ )
94
+
95
+ # Send to agent
96
+ self._send_message(user_input)
97
+ except Exception:
98
+ logger.exception("Error in on_input_submitted")
99
+
100
+ @work(exclusive=True)
101
+ async def _send_message(self, user_input: str) -> None:
102
+ logger.debug(f"Sending message to agent: {user_input}")
103
+
104
+ try:
105
+ chat_log = self.query_one("#chat-log", VerticalScroll)
106
+
107
+ # Show thinking indicator
108
+ thinking = LoadingIndicator()
109
+ await chat_log.mount(thinking)
110
+ chat_log.scroll_end(animate=False)
111
+
112
+ response = await self.agent.arun(user_input, session_id=self.session_id)
113
+
114
+ # Handle non-string responses
115
+ if not isinstance(response, str):
116
+ import json
117
+
118
+ response = json.dumps(response, indent=2)
119
+
120
+ # Remove thinking and show response
121
+ await thinking.remove()
122
+ logger.debug(f"Mounting response: '{response}'")
123
+
124
+ msg_widget = Static(f"{escape(response)}", classes="message agent-message")
125
+ await chat_log.mount(
126
+ Vertical(
127
+ msg_widget, classes="message-container message-container--agent"
128
+ )
129
+ )
130
+ chat_log.scroll_end(animate=True)
131
+
132
+ except Exception as e:
133
+ logger.exception("Error in _send_message")
134
+ # Try to report error to UI if possible
135
+ try:
136
+ chat_log = self.query_one("#chat-log", VerticalScroll)
137
+ # Ensure thinking is removed if it was added
138
+ try:
139
+ await thinking.remove()
140
+ except UnboundLocalError:
141
+ pass
142
+ except Exception:
143
+ logger.debug("Failed to remove thinking indicator")
144
+ pass
145
+
146
+ await chat_log.mount(
147
+ Static(f"[Error: {str(e)}]", classes="error-message", markup=False)
148
+ )
149
+ except Exception as e2:
150
+ logger.exception(f"Could not report error to UI: {e2}")
151
+
152
+ def action_show_help(self) -> None:
153
+ help_msg = (
154
+ "Available commands:\n"
155
+ " exit, quit, Ctrl+Q - End the chat session\n"
156
+ " help, Ctrl+H - Show this help message\n"
157
+ " clear, Ctrl+L - Clear conversation history"
158
+ )
159
+ self.query_one("#chat-log").mount(Static(help_msg, classes="system-message"))
160
+ self.query_one("#chat-log").scroll_end()
161
+
162
+ def action_clear_history(self) -> None:
163
+ """Clear conversation history."""
164
+ self.agent.clear_history(self.session_id)
165
+ self.query_one("#chat-log").mount(
166
+ Static(
167
+ "[Conversation history cleared]", classes="system-message", markup=False
168
+ )
169
+ )
170
+ self.query_one("#chat-log").scroll_end()
171
+
172
+
173
+ async def async_run_console_chat(
174
+ agent: AgentRunner,
175
+ *,
176
+ session_id: str | None = None,
177
+ ) -> None:
178
+ app = ChatApp(agent, session_id=session_id)
179
+ await app.run_async()
@@ -0,0 +1,105 @@
1
+ /* Main layout */
2
+ Screen {
3
+ layout: vertical;
4
+ background: $surface;
5
+ }
6
+
7
+ /* Header area */
8
+ Header {
9
+ dock: top;
10
+ height: 3;
11
+ content-align: center middle;
12
+ background: $primary-darken-2;
13
+ color: $text;
14
+ text-style: bold;
15
+ }
16
+
17
+ HeaderIcon {
18
+ display: none;
19
+ }
20
+
21
+ HeaderTitle {
22
+ content-align: center middle;
23
+ height: 100%;
24
+ }
25
+
26
+ /* Chat log area */
27
+ #chat-log {
28
+ height: 1fr;
29
+ padding: 1 2;
30
+ overflow-y: auto;
31
+ scrollbar-size: 1 1;
32
+ background: $surface;
33
+ }
34
+
35
+ /* Input area */
36
+ #chat-input {
37
+ dock: bottom;
38
+ height: 3;
39
+ margin: 1 0;
40
+ border: tall $primary-darken-2;
41
+ background: $surface-lighten-1;
42
+ }
43
+
44
+ /* Message Containers */
45
+ .message-container {
46
+ width: 100%;
47
+ height: auto;
48
+ margin-bottom: 1;
49
+ }
50
+
51
+ .message-container--user {
52
+ align: right middle;
53
+ }
54
+
55
+ .message-container--agent {
56
+ align: left middle;
57
+ }
58
+
59
+ /* Message Bubbles */
60
+ .message {
61
+ width: auto;
62
+ max-width: 80%;
63
+ height: auto;
64
+ padding: 0 1;
65
+ background: $surface;
66
+ color: $text;
67
+ }
68
+
69
+ .user-message {
70
+ color: $text;
71
+ border: round $primary;
72
+ }
73
+
74
+ .agent-message {
75
+ color: $text;
76
+ border: round $success;
77
+ }
78
+
79
+ .system-message {
80
+ width: 100%;
81
+ content-align: center middle;
82
+ color: $text-muted;
83
+ text-style: italic;
84
+ background: transparent;
85
+ border: none;
86
+ margin: 1 0;
87
+ }
88
+
89
+ .error-message {
90
+ background: $error-darken-2;
91
+ color: $text;
92
+ border: heavy $error-lighten-1;
93
+ }
94
+
95
+ .thinking-message {
96
+ color: $warning;
97
+ text-style: italic;
98
+ padding-left: 1;
99
+ }
100
+
101
+ LoadingIndicator {
102
+ height: 1;
103
+ color: $accent;
104
+ background: transparent;
105
+ }