psi-agent 0.0.1a2__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.
psi_agent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Psi Agent Platform - A portable, composable AI Agent platform."""
@@ -0,0 +1,5 @@
1
+ """Psi AI - LLM Caller interfaces."""
2
+
3
+ from . import openai
4
+
5
+ __all__ = ["openai"]
@@ -0,0 +1,197 @@
1
+ """Psi AI OpenAI - LLM Caller that exposes OpenAI-compatible API via Unix socket."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import tyro
11
+ from loguru import logger
12
+ from openai import AsyncOpenAI
13
+
14
+ from psi_agent.common import LLMRequest, LLMResponse
15
+
16
+
17
+ class AICaller:
18
+ """LLM Caller that forwards requests to OpenAI-compatible APIs."""
19
+
20
+ def __init__(
21
+ self,
22
+ session_socket: str,
23
+ api_key: str,
24
+ base_url: str,
25
+ model: str,
26
+ ) -> None:
27
+ self._session_socket = session_socket
28
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
29
+ self._model = model
30
+
31
+ logger.info(f"AI Caller initialized | model={model} | base_url={base_url}")
32
+ logger.debug(f"AI Caller config | socket={session_socket}")
33
+
34
+ async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
35
+ """Handle a single client connection."""
36
+ data = await reader.readline()
37
+ if not data:
38
+ logger.debug("Empty request received")
39
+ return
40
+
41
+ request = LLMRequest.model_validate(json.loads(data.decode()))
42
+ logger.info(
43
+ f"Request received | id={request.id} | stream={request.stream} | message_count={len(request.messages)}"
44
+ )
45
+ logger.debug(f"Request details | has_tools={request.tools is not None} | tool_choice={request.tool_choice}")
46
+
47
+ if request.stream:
48
+ await self._handle_stream(request, writer)
49
+ else:
50
+ await self._handle_non_stream(request, writer)
51
+
52
+ writer.close()
53
+ await writer.wait_closed()
54
+
55
+ async def _handle_stream(self, request: LLMRequest, writer: asyncio.StreamWriter) -> None:
56
+ """Handle streaming request."""
57
+ logger.debug(f"Starting stream | request_id={request.id}")
58
+
59
+ kwargs: dict[str, Any] = {
60
+ "model": self._model,
61
+ "messages": request.messages,
62
+ "stream": True,
63
+ }
64
+ if request.tools:
65
+ kwargs["tools"] = request.tools
66
+ kwargs["tool_choice"] = request.tool_choice
67
+
68
+ stream = await self._client.chat.completions.create(**kwargs)
69
+ chunk_count = 0
70
+
71
+ async for chunk in stream:
72
+ chunk_count += 1
73
+ chunk_data = chunk.model_dump()
74
+ response = LLMResponse(id=request.id, choices=chunk_data.get("choices", []))
75
+ writer.write((response.model_dump_json() + "\n").encode())
76
+ await writer.drain()
77
+
78
+ # Send done marker
79
+ done_response = LLMResponse(id=request.id, choices=[], done=True)
80
+ writer.write((done_response.model_dump_json() + "\n").encode())
81
+ await writer.drain()
82
+
83
+ logger.info(f"Stream complete | request_id={request.id} | chunks={chunk_count}")
84
+
85
+ async def _handle_non_stream(self, request: LLMRequest, writer: asyncio.StreamWriter) -> None:
86
+ """Handle non-streaming request."""
87
+ logger.debug(f"Starting non-stream request | request_id={request.id}")
88
+
89
+ kwargs: dict[str, Any] = {
90
+ "model": self._model,
91
+ "messages": request.messages,
92
+ }
93
+ if request.tools:
94
+ kwargs["tools"] = request.tools
95
+ kwargs["tool_choice"] = request.tool_choice
96
+
97
+ response = await self._client.chat.completions.create(**kwargs)
98
+ response_data = response.model_dump()
99
+ llm_response = LLMResponse(id=request.id, choices=response_data.get("choices", []))
100
+ writer.write((llm_response.model_dump_json() + "\n").encode())
101
+ await writer.drain()
102
+
103
+ content = response_data.get("choices", [{}])[0].get("message", {}).get("content", "")
104
+ logger.info(f"Non-stream complete | request_id={request.id} | response_length={len(content)}")
105
+
106
+ async def run(self) -> None:
107
+ """Start the Unix socket server."""
108
+ socket_path = Path(self._session_socket)
109
+ if socket_path.exists():
110
+ socket_path.unlink()
111
+ logger.debug(f"Removed existing socket | path={self._session_socket}")
112
+
113
+ server = await asyncio.start_unix_server(
114
+ self.handle_client,
115
+ path=self._session_socket,
116
+ )
117
+
118
+ logger.info(f"AI server started | socket={self._session_socket} | model={self._model}")
119
+
120
+ async with server:
121
+ await server.serve_forever()
122
+
123
+
124
+ async def run_ai(
125
+ session_socket: str,
126
+ model: str,
127
+ api_key: str,
128
+ base_url: str = "https://api.openai.com/v1",
129
+ log_level: str = "INFO",
130
+ ) -> None:
131
+ """Python function interface to run the AI caller.
132
+
133
+ Args:
134
+ session_socket: Unix socket path to listen on
135
+ model: Model name to use
136
+ api_key: API key for the LLM provider
137
+ base_url: API base URL
138
+ log_level: Log level (DEBUG, INFO, WARNING, ERROR)
139
+ """
140
+ _setup_logger(log_level)
141
+ caller = AICaller(
142
+ session_socket=session_socket,
143
+ api_key=api_key,
144
+ base_url=base_url,
145
+ model=model,
146
+ )
147
+ await caller.run()
148
+
149
+
150
+ def _setup_logger(log_level: str) -> None:
151
+ """Configure logger."""
152
+ logger.remove()
153
+ logger.add(
154
+ lambda msg: print(msg, end=""),
155
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>ai-openai</cyan> | {message}",
156
+ level=log_level,
157
+ )
158
+
159
+
160
+ @dataclass
161
+ class CliArgs:
162
+ """AI Caller CLI arguments."""
163
+
164
+ session_socket: str
165
+ """Unix socket path to listen on"""
166
+
167
+ model: str
168
+ """Model name to use"""
169
+
170
+ api_key: str | None = None
171
+ """API key (or set API_KEY env var)"""
172
+
173
+ base_url: str = "https://api.openai.com/v1"
174
+ """API base URL"""
175
+
176
+ log_level: str = "INFO"
177
+ """Log level (DEBUG, INFO, WARNING, ERROR)"""
178
+
179
+
180
+ def main() -> None:
181
+ args = tyro.cli(CliArgs)
182
+
183
+ api_key = args.api_key or os.environ.get("API_KEY") or ""
184
+
185
+ asyncio.run(
186
+ run_ai(
187
+ session_socket=args.session_socket,
188
+ model=args.model,
189
+ api_key=api_key,
190
+ base_url=args.base_url,
191
+ log_level=args.log_level,
192
+ )
193
+ )
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
@@ -0,0 +1,5 @@
1
+ """Psi Channel - User interaction interfaces."""
2
+
3
+ from . import tui
4
+
5
+ __all__ = ["tui"]
@@ -0,0 +1,117 @@
1
+ """Psi Channel - TUI interface for user interaction."""
2
+
3
+ import asyncio
4
+ import json
5
+ import sys
6
+ from dataclasses import dataclass
7
+
8
+ import tyro
9
+ from loguru import logger
10
+ from prompt_toolkit import PromptSession
11
+ from prompt_toolkit.styles import Style
12
+
13
+ from psi_agent.common import AssistantMessage, UserMessage
14
+
15
+
16
+ class Channel:
17
+ """Terminal UI channel for user interaction."""
18
+
19
+ def __init__(self, session_socket: str) -> None:
20
+ self._session_socket = session_socket
21
+ logger.debug(f"Channel initialized | socket={session_socket}")
22
+
23
+ async def run(self) -> None:
24
+ """Run the TUI interface."""
25
+ logger.info(f"Connecting to session at {self._session_socket}")
26
+ print(f"Connecting to session at {self._session_socket}", file=sys.stderr)
27
+
28
+ reader, writer = await asyncio.open_unix_connection(self._session_socket)
29
+ logger.info("Connected to session")
30
+
31
+ style = Style.from_dict(
32
+ {
33
+ "user": "#ansigreen",
34
+ "assistant": "#ansicyan",
35
+ }
36
+ )
37
+
38
+ prompt_session: PromptSession[str] = PromptSession(style=style)
39
+
40
+ print("Connected. Ctrl+C to exit.")
41
+
42
+ while True:
43
+ try:
44
+ user_input = await prompt_session.prompt_async("You: ", style=style)
45
+ logger.debug(f"User input received | length={len(user_input)}")
46
+
47
+ if not user_input:
48
+ continue
49
+
50
+ message = UserMessage(content=user_input)
51
+ writer.write((message.model_dump_json() + "\n").encode())
52
+ await writer.drain()
53
+ logger.debug(f"Message sent to session | content={user_input[:50]}")
54
+
55
+ data = await reader.readline()
56
+ if not data:
57
+ logger.warning("Session disconnected")
58
+ print("Session disconnected.")
59
+ break
60
+
61
+ response = AssistantMessage.model_validate(json.loads(data.decode()))
62
+ logger.debug(f"Response received | length={len(response.content)}")
63
+ print(f"Assistant: {response.content}")
64
+
65
+ except KeyboardInterrupt:
66
+ logger.info("User interrupted with Ctrl+C")
67
+ print("\nExiting...")
68
+ break
69
+ except EOFError:
70
+ logger.info("EOF received")
71
+ break
72
+
73
+ writer.close()
74
+ await writer.wait_closed()
75
+ logger.info("Channel closed")
76
+
77
+
78
+ async def run_channel(session_socket: str, log_level: str = "WARNING") -> None:
79
+ """Python function interface to run the TUI channel.
80
+
81
+ Args:
82
+ session_socket: Unix socket path for session connection
83
+ log_level: Log level (DEBUG, INFO, WARNING, ERROR). Default WARNING to not show logs in TUI.
84
+ """
85
+ _setup_logger(log_level)
86
+ channel = Channel(session_socket=session_socket)
87
+ await channel.run()
88
+
89
+
90
+ def _setup_logger(log_level: str) -> None:
91
+ """Configure logger."""
92
+ logger.remove()
93
+ logger.add(
94
+ lambda msg: print(msg, end=""),
95
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>channel-tui</cyan> | {message}",
96
+ level=log_level,
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class CliArgs:
102
+ """Channel TUI CLI arguments."""
103
+
104
+ session_socket: str
105
+ """Unix socket path for session connection"""
106
+
107
+ log_level: str = "WARNING"
108
+ """Log level (DEBUG, INFO, WARNING, ERROR). Default WARNING to not show logs in TUI"""
109
+
110
+
111
+ def main() -> None:
112
+ args = tyro.cli(CliArgs)
113
+ asyncio.run(run_channel(session_socket=args.session_socket, log_level=args.log_level))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,5 @@
1
+ """Psi common utilities."""
2
+
3
+ from .protocol import AssistantMessage, LLMRequest, LLMResponse, ToolResult, UserMessage
4
+
5
+ __all__ = ["LLMRequest", "LLMResponse", "ToolResult", "UserMessage", "AssistantMessage"]
@@ -0,0 +1,49 @@
1
+ """Shared protocol definitions for Psi Agent Platform."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class LLMRequest(BaseModel):
9
+ """Request to LLM Caller (OpenAI compatible)."""
10
+
11
+ id: str
12
+ messages: list[dict[str, Any]]
13
+ tools: list[dict[str, Any]] | None = None
14
+ tool_choice: str = "auto"
15
+ stream: bool = True
16
+
17
+
18
+ class LLMResponse(BaseModel):
19
+ """Response from LLM Caller (OpenAI compatible)."""
20
+
21
+ id: str
22
+ choices: list[dict[str, Any]]
23
+ done: bool = False
24
+ error: str | None = None
25
+
26
+
27
+ class ToolResult(BaseModel):
28
+ """Result from tool execution."""
29
+
30
+ success: bool
31
+ content: str | None = None
32
+ error: str | None = None
33
+ stdout: str | None = None
34
+ stderr: str | None = None
35
+ returncode: int | None = None
36
+
37
+
38
+ class UserMessage(BaseModel):
39
+ """Message from user to session."""
40
+
41
+ role: str = "user"
42
+ content: str
43
+
44
+
45
+ class AssistantMessage(BaseModel):
46
+ """Message from assistant to channel."""
47
+
48
+ role: str = "assistant"
49
+ content: str