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/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
|
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
|
+
}
|