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 +1 -0
- psi_agent/ai/__init__.py +5 -0
- psi_agent/ai/openai/__init__.py +197 -0
- psi_agent/channel/__init__.py +5 -0
- psi_agent/channel/tui/__init__.py +117 -0
- psi_agent/common/__init__.py +5 -0
- psi_agent/common/protocol.py +49 -0
- psi_agent/session/__init__.py +522 -0
- psi_agent/workspace/__init__.py +556 -0
- psi_agent-0.0.1a2.dist-info/METADATA +18 -0
- psi_agent-0.0.1a2.dist-info/RECORD +14 -0
- psi_agent-0.0.1a2.dist-info/WHEEL +4 -0
- psi_agent-0.0.1a2.dist-info/entry_points.txt +8 -0
- psi_agent-0.0.1a2.dist-info/licenses/LICENSE.md +660 -0
psi_agent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Psi Agent Platform - A portable, composable AI Agent platform."""
|
psi_agent/ai/__init__.py
ADDED
|
@@ -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,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,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
|