paimon 0.1.0__tar.gz
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.
- paimon-0.1.0/PKG-INFO +45 -0
- paimon-0.1.0/README.md +36 -0
- paimon-0.1.0/paimon/__init__.py +1 -0
- paimon-0.1.0/paimon/agent.py +159 -0
- paimon-0.1.0/paimon/app.py +171 -0
- paimon-0.1.0/paimon/config.py +9 -0
- paimon-0.1.0/paimon/tools.py +157 -0
- paimon-0.1.0/paimon.egg-info/PKG-INFO +45 -0
- paimon-0.1.0/paimon.egg-info/SOURCES.txt +13 -0
- paimon-0.1.0/paimon.egg-info/dependency_links.txt +1 -0
- paimon-0.1.0/paimon.egg-info/entry_points.txt +2 -0
- paimon-0.1.0/paimon.egg-info/requires.txt +2 -0
- paimon-0.1.0/paimon.egg-info/top_level.txt +1 -0
- paimon-0.1.0/pyproject.toml +19 -0
- paimon-0.1.0/setup.cfg +4 -0
paimon-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paimon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal code agent built on litellm + textual
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: litellm>=1.89.4
|
|
8
|
+
Requires-Dist: textual>=8.2.7
|
|
9
|
+
|
|
10
|
+
# Paimon
|
|
11
|
+
|
|
12
|
+
A minimal terminal code agent built on **litellm** (LLM access) and **textual** (TUI).
|
|
13
|
+
|
|
14
|
+
## Features (MVP)
|
|
15
|
+
|
|
16
|
+
- Streaming agent loop: LLM + tool calls until the task is done
|
|
17
|
+
- Four tools: `read_file`, `write_file`, `edit_file`, `bash`
|
|
18
|
+
- Live streaming output + reasoning display in a Textual UI
|
|
19
|
+
- Confirmation prompt before dangerous actions (`bash`, `write_file`, `edit_file`)
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
Set these environment variables (uses an OpenAI-compatible endpoint via the
|
|
24
|
+
litellm `openai/` prefix):
|
|
25
|
+
|
|
26
|
+
| Variable | Description | Example |
|
|
27
|
+
|----------|-------------|---------|
|
|
28
|
+
| `PAIMON_API_KEY` | API key for the endpoint | `tp-...` |
|
|
29
|
+
| `PAIMON_MODEL` | litellm model id | `openai/mimo-v2.5-pro` |
|
|
30
|
+
| `PAIMON_API_BASE` | base URL of the endpoint | `https://token-plan-cn.xiaomimimo.com/v1` |
|
|
31
|
+
|
|
32
|
+
## Run
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run paimon # or: uv run main.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Layout
|
|
39
|
+
|
|
40
|
+
| File | Role |
|
|
41
|
+
|------|------|
|
|
42
|
+
| `paimon/config.py` | model config |
|
|
43
|
+
| `paimon/tools.py` | tool schemas + execution |
|
|
44
|
+
| `paimon/agent.py` | UI-agnostic agent loop (yields typed events) |
|
|
45
|
+
| `paimon/app.py` | Textual TUI |
|
paimon-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Paimon
|
|
2
|
+
|
|
3
|
+
A minimal terminal code agent built on **litellm** (LLM access) and **textual** (TUI).
|
|
4
|
+
|
|
5
|
+
## Features (MVP)
|
|
6
|
+
|
|
7
|
+
- Streaming agent loop: LLM + tool calls until the task is done
|
|
8
|
+
- Four tools: `read_file`, `write_file`, `edit_file`, `bash`
|
|
9
|
+
- Live streaming output + reasoning display in a Textual UI
|
|
10
|
+
- Confirmation prompt before dangerous actions (`bash`, `write_file`, `edit_file`)
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Set these environment variables (uses an OpenAI-compatible endpoint via the
|
|
15
|
+
litellm `openai/` prefix):
|
|
16
|
+
|
|
17
|
+
| Variable | Description | Example |
|
|
18
|
+
|----------|-------------|---------|
|
|
19
|
+
| `PAIMON_API_KEY` | API key for the endpoint | `tp-...` |
|
|
20
|
+
| `PAIMON_MODEL` | litellm model id | `openai/mimo-v2.5-pro` |
|
|
21
|
+
| `PAIMON_API_BASE` | base URL of the endpoint | `https://token-plan-cn.xiaomimimo.com/v1` |
|
|
22
|
+
|
|
23
|
+
## Run
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv run paimon # or: uv run main.py
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Layout
|
|
30
|
+
|
|
31
|
+
| File | Role |
|
|
32
|
+
|------|------|
|
|
33
|
+
| `paimon/config.py` | model config |
|
|
34
|
+
| `paimon/tools.py` | tool schemas + execution |
|
|
35
|
+
| `paimon/agent.py` | UI-agnostic agent loop (yields typed events) |
|
|
36
|
+
| `paimon/app.py` | Textual TUI |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Paimon, a minimal code agent built on litellm + textual."""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""The agent loop: stream from the LLM, run tool calls, repeat until done.
|
|
2
|
+
|
|
3
|
+
``Agent.run`` is UI-agnostic: it yields typed events that a CLI or a TUI can
|
|
4
|
+
render however it likes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import AsyncIterator, Awaitable, Callable, Optional
|
|
12
|
+
|
|
13
|
+
import litellm
|
|
14
|
+
|
|
15
|
+
from . import config, tools
|
|
16
|
+
|
|
17
|
+
litellm.telemetry = False
|
|
18
|
+
litellm.suppress_debug_info = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---- Events yielded by Agent.run -------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TextDelta:
|
|
26
|
+
text: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ReasoningDelta:
|
|
31
|
+
text: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ToolStart:
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
args: dict
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ToolEnd:
|
|
43
|
+
id: str
|
|
44
|
+
name: str
|
|
45
|
+
result: str
|
|
46
|
+
denied: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class TurnEnd:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# A confirm callback returns True to allow a dangerous tool, False to deny.
|
|
55
|
+
ConfirmFn = Callable[[str, dict], Awaitable[bool]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _system_prompt(cwd: Path) -> str:
|
|
59
|
+
return f"""You are Paimon, a concise coding assistant operating in a terminal.
|
|
60
|
+
|
|
61
|
+
You help with software engineering tasks by reading and editing files and running
|
|
62
|
+
shell commands. You have these tools: read_file, write_file, edit_file, bash.
|
|
63
|
+
|
|
64
|
+
Guidelines:
|
|
65
|
+
- Working directory: {cwd}
|
|
66
|
+
- Today's date: {date.today().isoformat()}
|
|
67
|
+
- Prefer reading a file before editing it. For edits, use edit_file with a unique
|
|
68
|
+
old_string; only use write_file for new files or full rewrites.
|
|
69
|
+
- Use the bash tool for listing, searching (grep/find/ls), git, and running tests.
|
|
70
|
+
- Be direct. When the task is done, briefly state what you did. Don't narrate every step.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Agent:
|
|
75
|
+
def __init__(self, cwd: Optional[Path] = None, confirm: Optional[ConfirmFn] = None):
|
|
76
|
+
self.cwd = Path(cwd or Path.cwd())
|
|
77
|
+
self.confirm = confirm
|
|
78
|
+
self.messages: list[dict] = [
|
|
79
|
+
{"role": "system", "content": _system_prompt(self.cwd)}
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
async def run(self, user_input: str) -> AsyncIterator[object]:
|
|
83
|
+
"""Run one user turn to completion, yielding events along the way."""
|
|
84
|
+
self.messages.append({"role": "user", "content": user_input})
|
|
85
|
+
|
|
86
|
+
while True:
|
|
87
|
+
response = await litellm.acompletion(
|
|
88
|
+
model=config.MODEL,
|
|
89
|
+
api_base=config.API_BASE,
|
|
90
|
+
api_key=config.API_KEY,
|
|
91
|
+
messages=self.messages,
|
|
92
|
+
tools=tools.TOOLS,
|
|
93
|
+
stream=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
content = ""
|
|
97
|
+
# index -> {"id", "name", "args"} accumulated across stream deltas
|
|
98
|
+
calls: dict[int, dict] = {}
|
|
99
|
+
|
|
100
|
+
async for chunk in response:
|
|
101
|
+
delta = chunk.choices[0].delta
|
|
102
|
+
|
|
103
|
+
reasoning = getattr(delta, "reasoning_content", None)
|
|
104
|
+
if reasoning:
|
|
105
|
+
yield ReasoningDelta(reasoning)
|
|
106
|
+
|
|
107
|
+
if delta.content:
|
|
108
|
+
content += delta.content
|
|
109
|
+
yield TextDelta(delta.content)
|
|
110
|
+
|
|
111
|
+
for tc in delta.tool_calls or []:
|
|
112
|
+
slot = calls.setdefault(tc.index, {"id": "", "name": "", "args": ""})
|
|
113
|
+
if tc.id:
|
|
114
|
+
slot["id"] = tc.id
|
|
115
|
+
if tc.function and tc.function.name:
|
|
116
|
+
slot["name"] = tc.function.name
|
|
117
|
+
if tc.function and tc.function.arguments:
|
|
118
|
+
slot["args"] += tc.function.arguments
|
|
119
|
+
|
|
120
|
+
ordered = [calls[i] for i in sorted(calls)]
|
|
121
|
+
|
|
122
|
+
assistant_msg: dict = {"role": "assistant", "content": content or None}
|
|
123
|
+
if ordered:
|
|
124
|
+
assistant_msg["tool_calls"] = [
|
|
125
|
+
{
|
|
126
|
+
"id": c["id"],
|
|
127
|
+
"type": "function",
|
|
128
|
+
"function": {"name": c["name"], "arguments": c["args"]},
|
|
129
|
+
}
|
|
130
|
+
for c in ordered
|
|
131
|
+
]
|
|
132
|
+
self.messages.append(assistant_msg)
|
|
133
|
+
|
|
134
|
+
if not ordered:
|
|
135
|
+
yield TurnEnd()
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
for c in ordered:
|
|
139
|
+
try:
|
|
140
|
+
args = json.loads(c["args"] or "{}")
|
|
141
|
+
except json.JSONDecodeError:
|
|
142
|
+
args = {}
|
|
143
|
+
name = c["name"]
|
|
144
|
+
yield ToolStart(c["id"], name, args)
|
|
145
|
+
|
|
146
|
+
denied = False
|
|
147
|
+
if self.confirm and name in tools.DANGEROUS:
|
|
148
|
+
allowed = await self.confirm(name, args)
|
|
149
|
+
if not allowed:
|
|
150
|
+
denied = True
|
|
151
|
+
result = "User denied this operation."
|
|
152
|
+
if not denied:
|
|
153
|
+
result = await tools.execute_tool(name, args, self.cwd)
|
|
154
|
+
|
|
155
|
+
yield ToolEnd(c["id"], name, result, denied=denied)
|
|
156
|
+
self.messages.append(
|
|
157
|
+
{"role": "tool", "tool_call_id": c["id"], "content": result}
|
|
158
|
+
)
|
|
159
|
+
# loop again so the model can react to tool results
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Textual TUI for the Paimon agent."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
from textual import on, work
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Vertical, VerticalScroll
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Input, Static
|
|
12
|
+
|
|
13
|
+
from .agent import (
|
|
14
|
+
Agent,
|
|
15
|
+
ReasoningDelta,
|
|
16
|
+
TextDelta,
|
|
17
|
+
ToolEnd,
|
|
18
|
+
ToolStart,
|
|
19
|
+
TurnEnd,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
24
|
+
"""Yes/No confirmation for a dangerous tool call."""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [("y", "allow", "Allow"), ("n", "deny", "Deny"), ("escape", "deny", "Deny")]
|
|
27
|
+
|
|
28
|
+
def __init__(self, tool_name: str, args: dict) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.tool_name = tool_name
|
|
31
|
+
self.args = args
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
detail = self.args.get("command") or self.args.get("path") or ""
|
|
35
|
+
body = Text()
|
|
36
|
+
body.append("Allow this action?\n\n", style="bold")
|
|
37
|
+
body.append(f"{self.tool_name}", style="bold yellow")
|
|
38
|
+
body.append(f" {detail}", style="dim")
|
|
39
|
+
with Vertical(id="confirm-box"):
|
|
40
|
+
yield Static(body)
|
|
41
|
+
with Vertical(id="confirm-buttons"):
|
|
42
|
+
yield Button("Allow (y)", variant="success", id="allow")
|
|
43
|
+
yield Button("Deny (n)", variant="error", id="deny")
|
|
44
|
+
|
|
45
|
+
@on(Button.Pressed, "#allow")
|
|
46
|
+
def action_allow(self) -> None:
|
|
47
|
+
self.dismiss(True)
|
|
48
|
+
|
|
49
|
+
@on(Button.Pressed, "#deny")
|
|
50
|
+
def action_deny(self) -> None:
|
|
51
|
+
self.dismiss(False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PaimonApp(App):
|
|
55
|
+
CSS = """
|
|
56
|
+
#log { height: 1fr; padding: 0 1; }
|
|
57
|
+
#log > Static { margin-bottom: 1; }
|
|
58
|
+
ConfirmScreen { align: center middle; }
|
|
59
|
+
#confirm-box { width: 70%; height: auto; padding: 1 2; border: round $warning; background: $surface; }
|
|
60
|
+
#confirm-buttons { height: auto; margin-top: 1; }
|
|
61
|
+
#confirm-buttons Button { margin-right: 2; width: auto; }
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
BINDINGS = [("ctrl+c", "quit", "Quit")]
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.agent = Agent(cwd=Path.cwd(), confirm=self._confirm)
|
|
69
|
+
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
yield VerticalScroll(id="log")
|
|
72
|
+
yield Input(placeholder="Ask Paimon to do something… (Ctrl+C to quit)")
|
|
73
|
+
|
|
74
|
+
def on_mount(self) -> None:
|
|
75
|
+
self.query_one(Input).focus()
|
|
76
|
+
|
|
77
|
+
# ---- rendering helpers --------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _add(self, renderable) -> Static:
|
|
80
|
+
log = self.query_one("#log", VerticalScroll)
|
|
81
|
+
widget = Static(renderable)
|
|
82
|
+
log.mount(widget)
|
|
83
|
+
log.scroll_end(animate=False)
|
|
84
|
+
return widget
|
|
85
|
+
|
|
86
|
+
def _scroll(self) -> None:
|
|
87
|
+
self.query_one("#log", VerticalScroll).scroll_end(animate=False)
|
|
88
|
+
|
|
89
|
+
# ---- confirmation hook (called from the agent loop) --------------------
|
|
90
|
+
|
|
91
|
+
async def _confirm(self, tool_name: str, args: dict) -> bool:
|
|
92
|
+
return await self.push_screen_wait(ConfirmScreen(tool_name, args))
|
|
93
|
+
|
|
94
|
+
# ---- input → turn -------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@on(Input.Submitted)
|
|
97
|
+
def handle_submit(self, event: Input.Submitted) -> None:
|
|
98
|
+
text = event.value.strip()
|
|
99
|
+
if not text:
|
|
100
|
+
return
|
|
101
|
+
event.input.value = ""
|
|
102
|
+
header = Text("You\n", style="bold cyan")
|
|
103
|
+
header.append(text)
|
|
104
|
+
self._add(header)
|
|
105
|
+
self.run_turn(text)
|
|
106
|
+
|
|
107
|
+
@work(exclusive=True)
|
|
108
|
+
async def run_turn(self, text: str) -> None:
|
|
109
|
+
inp = self.query_one(Input)
|
|
110
|
+
inp.disabled = True
|
|
111
|
+
inp.placeholder = "Paimon is working…"
|
|
112
|
+
|
|
113
|
+
assistant: Static | None = None
|
|
114
|
+
buffer = ""
|
|
115
|
+
reasoning: Static | None = None
|
|
116
|
+
reasoning_buf = ""
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
async for ev in self.agent.run(text):
|
|
120
|
+
if isinstance(ev, ReasoningDelta):
|
|
121
|
+
reasoning_buf += ev.text
|
|
122
|
+
body = Text(reasoning_buf, style="dim italic")
|
|
123
|
+
if reasoning is None:
|
|
124
|
+
reasoning = self._add(body)
|
|
125
|
+
else:
|
|
126
|
+
reasoning.update(body)
|
|
127
|
+
self._scroll()
|
|
128
|
+
|
|
129
|
+
elif isinstance(ev, TextDelta):
|
|
130
|
+
buffer += ev.text
|
|
131
|
+
body = Text("Paimon\n", style="bold green")
|
|
132
|
+
body.append(buffer)
|
|
133
|
+
if assistant is None:
|
|
134
|
+
assistant = self._add(body)
|
|
135
|
+
else:
|
|
136
|
+
assistant.update(body)
|
|
137
|
+
self._scroll()
|
|
138
|
+
|
|
139
|
+
elif isinstance(ev, ToolStart):
|
|
140
|
+
detail = ev.args.get("command") or ev.args.get("path") or json.dumps(ev.args)
|
|
141
|
+
line = Text("⚙ ", style="yellow")
|
|
142
|
+
line.append(ev.name, style="bold yellow")
|
|
143
|
+
line.append(f" {detail}", style="dim")
|
|
144
|
+
self._add(line)
|
|
145
|
+
# start fresh assistant/reasoning blocks after a tool runs
|
|
146
|
+
assistant, buffer = None, ""
|
|
147
|
+
reasoning, reasoning_buf = None, ""
|
|
148
|
+
|
|
149
|
+
elif isinstance(ev, ToolEnd):
|
|
150
|
+
preview = "\n".join(ev.result.splitlines()[:15])
|
|
151
|
+
if len(ev.result.splitlines()) > 15:
|
|
152
|
+
preview += "\n…"
|
|
153
|
+
style = "red" if ev.denied else "dim"
|
|
154
|
+
self._add(Text(preview or "(no output)", style=style))
|
|
155
|
+
|
|
156
|
+
elif isinstance(ev, TurnEnd):
|
|
157
|
+
pass
|
|
158
|
+
except Exception as exc: # noqa: BLE001 — show errors instead of crashing the UI
|
|
159
|
+
self._add(Text(f"Error: {exc}", style="bold red"))
|
|
160
|
+
finally:
|
|
161
|
+
inp.disabled = False
|
|
162
|
+
inp.placeholder = "Ask Paimon to do something… (Ctrl+C to quit)"
|
|
163
|
+
inp.focus()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main() -> None:
|
|
167
|
+
PaimonApp().run()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Configuration: model settings read from environment variables.
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# litellm uses the "openai/" prefix to talk to any OpenAI-compatible endpoint.
|
|
7
|
+
MODEL = os.environ.get("PAIMON_MODEL")
|
|
8
|
+
API_BASE = os.environ.get("PAIMON_API_BASE")
|
|
9
|
+
API_KEY = os.environ.get("PAIMON_API_KEY")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tool definitions and execution.
|
|
2
|
+
|
|
3
|
+
Each tool is described with an OpenAI-style JSON schema (sent to the model) and
|
|
4
|
+
implemented by a small Python function. ``execute_tool`` dispatches by name.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Tools whose side effects warrant a user confirmation before running.
|
|
11
|
+
DANGEROUS = {"bash", "write_file", "edit_file"}
|
|
12
|
+
|
|
13
|
+
MAX_OUTPUT = 30_000 # truncate tool output sent back to the model
|
|
14
|
+
|
|
15
|
+
TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
"type": "function",
|
|
18
|
+
"function": {
|
|
19
|
+
"name": "read_file",
|
|
20
|
+
"description": "Read a text file and return its contents with line numbers.",
|
|
21
|
+
"parameters": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"path": {"type": "string", "description": "File path, relative to the working directory or absolute."},
|
|
25
|
+
"offset": {"type": "integer", "description": "1-indexed line to start from (optional)."},
|
|
26
|
+
"limit": {"type": "integer", "description": "Maximum number of lines to read (optional)."},
|
|
27
|
+
},
|
|
28
|
+
"required": ["path"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"type": "function",
|
|
34
|
+
"function": {
|
|
35
|
+
"name": "write_file",
|
|
36
|
+
"description": "Create or overwrite a file with the given content.",
|
|
37
|
+
"parameters": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"path": {"type": "string"},
|
|
41
|
+
"content": {"type": "string"},
|
|
42
|
+
},
|
|
43
|
+
"required": ["path", "content"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"type": "function",
|
|
49
|
+
"function": {
|
|
50
|
+
"name": "edit_file",
|
|
51
|
+
"description": "Replace an exact substring in a file. old_string must appear exactly once.",
|
|
52
|
+
"parameters": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"properties": {
|
|
55
|
+
"path": {"type": "string"},
|
|
56
|
+
"old_string": {"type": "string", "description": "Exact text to replace (must be unique in the file)."},
|
|
57
|
+
"new_string": {"type": "string", "description": "Replacement text."},
|
|
58
|
+
},
|
|
59
|
+
"required": ["path", "old_string", "new_string"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"type": "function",
|
|
65
|
+
"function": {
|
|
66
|
+
"name": "bash",
|
|
67
|
+
"description": "Run a shell command in the working directory and return its combined stdout/stderr. Use this for listing, searching (grep/find/ls), git, running tests, etc.",
|
|
68
|
+
"parameters": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"command": {"type": "string"},
|
|
72
|
+
},
|
|
73
|
+
"required": ["command"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve(path: str, cwd: Path) -> Path:
|
|
81
|
+
p = Path(path)
|
|
82
|
+
return p if p.is_absolute() else cwd / p
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_file(args: dict, cwd: Path) -> str:
|
|
86
|
+
path = _resolve(args["path"], cwd)
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return f"Error: file not found: {path}"
|
|
89
|
+
lines = path.read_text(errors="replace").splitlines()
|
|
90
|
+
offset = max(1, int(args.get("offset", 1)))
|
|
91
|
+
limit = args.get("limit")
|
|
92
|
+
end = offset - 1 + int(limit) if limit else len(lines)
|
|
93
|
+
selected = lines[offset - 1 : end]
|
|
94
|
+
if not selected:
|
|
95
|
+
return "(file is empty or offset past end of file)"
|
|
96
|
+
width = len(str(offset + len(selected) - 1))
|
|
97
|
+
return "\n".join(f"{offset + i:>{width}} {line}" for i, line in enumerate(selected))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_file(args: dict, cwd: Path) -> str:
|
|
101
|
+
path = _resolve(args["path"], cwd)
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
path.write_text(args["content"])
|
|
104
|
+
n = args["content"].count("\n") + 1
|
|
105
|
+
return f"Wrote {n} lines to {path}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _edit_file(args: dict, cwd: Path) -> str:
|
|
109
|
+
path = _resolve(args["path"], cwd)
|
|
110
|
+
if not path.exists():
|
|
111
|
+
return f"Error: file not found: {path}"
|
|
112
|
+
text = path.read_text()
|
|
113
|
+
old = args["old_string"]
|
|
114
|
+
count = text.count(old)
|
|
115
|
+
if count == 0:
|
|
116
|
+
return "Error: old_string not found in file."
|
|
117
|
+
if count > 1:
|
|
118
|
+
return f"Error: old_string is not unique (found {count} times). Add more context to make it unique."
|
|
119
|
+
path.write_text(text.replace(old, args["new_string"], 1))
|
|
120
|
+
return f"Edited {path}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _bash(args: dict, cwd: Path) -> str:
|
|
124
|
+
proc = await asyncio.create_subprocess_shell(
|
|
125
|
+
args["command"],
|
|
126
|
+
cwd=str(cwd),
|
|
127
|
+
stdout=asyncio.subprocess.PIPE,
|
|
128
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
|
132
|
+
except asyncio.TimeoutError:
|
|
133
|
+
proc.kill()
|
|
134
|
+
return "Error: command timed out after 120s."
|
|
135
|
+
out = stdout.decode(errors="replace")
|
|
136
|
+
status = f"(exit code {proc.returncode})"
|
|
137
|
+
return f"{out}\n{status}" if out.strip() else status
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def execute_tool(name: str, args: dict, cwd: Path) -> str:
|
|
141
|
+
"""Dispatch a tool call. Always returns a string for the model."""
|
|
142
|
+
try:
|
|
143
|
+
if name == "read_file":
|
|
144
|
+
return _read_file(args, cwd)
|
|
145
|
+
if name == "write_file":
|
|
146
|
+
return _write_file(args, cwd)
|
|
147
|
+
if name == "edit_file":
|
|
148
|
+
return _edit_file(args, cwd)
|
|
149
|
+
if name == "bash":
|
|
150
|
+
result = await _bash(args, cwd)
|
|
151
|
+
else:
|
|
152
|
+
return f"Error: unknown tool {name!r}"
|
|
153
|
+
except Exception as exc: # noqa: BLE001 — surface any tool error to the model
|
|
154
|
+
return f"Error executing {name}: {exc}"
|
|
155
|
+
if len(result) > MAX_OUTPUT:
|
|
156
|
+
result = result[:MAX_OUTPUT] + f"\n... (truncated, {len(result) - MAX_OUTPUT} more chars)"
|
|
157
|
+
return result
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paimon
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A minimal code agent built on litellm + textual
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: litellm>=1.89.4
|
|
8
|
+
Requires-Dist: textual>=8.2.7
|
|
9
|
+
|
|
10
|
+
# Paimon
|
|
11
|
+
|
|
12
|
+
A minimal terminal code agent built on **litellm** (LLM access) and **textual** (TUI).
|
|
13
|
+
|
|
14
|
+
## Features (MVP)
|
|
15
|
+
|
|
16
|
+
- Streaming agent loop: LLM + tool calls until the task is done
|
|
17
|
+
- Four tools: `read_file`, `write_file`, `edit_file`, `bash`
|
|
18
|
+
- Live streaming output + reasoning display in a Textual UI
|
|
19
|
+
- Confirmation prompt before dangerous actions (`bash`, `write_file`, `edit_file`)
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
Set these environment variables (uses an OpenAI-compatible endpoint via the
|
|
24
|
+
litellm `openai/` prefix):
|
|
25
|
+
|
|
26
|
+
| Variable | Description | Example |
|
|
27
|
+
|----------|-------------|---------|
|
|
28
|
+
| `PAIMON_API_KEY` | API key for the endpoint | `tp-...` |
|
|
29
|
+
| `PAIMON_MODEL` | litellm model id | `openai/mimo-v2.5-pro` |
|
|
30
|
+
| `PAIMON_API_BASE` | base URL of the endpoint | `https://token-plan-cn.xiaomimimo.com/v1` |
|
|
31
|
+
|
|
32
|
+
## Run
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run paimon # or: uv run main.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Layout
|
|
39
|
+
|
|
40
|
+
| File | Role |
|
|
41
|
+
|------|------|
|
|
42
|
+
| `paimon/config.py` | model config |
|
|
43
|
+
| `paimon/tools.py` | tool schemas + execution |
|
|
44
|
+
| `paimon/agent.py` | UI-agnostic agent loop (yields typed events) |
|
|
45
|
+
| `paimon/app.py` | Textual TUI |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
paimon/__init__.py
|
|
4
|
+
paimon/agent.py
|
|
5
|
+
paimon/app.py
|
|
6
|
+
paimon/config.py
|
|
7
|
+
paimon/tools.py
|
|
8
|
+
paimon.egg-info/PKG-INFO
|
|
9
|
+
paimon.egg-info/SOURCES.txt
|
|
10
|
+
paimon.egg-info/dependency_links.txt
|
|
11
|
+
paimon.egg-info/entry_points.txt
|
|
12
|
+
paimon.egg-info/requires.txt
|
|
13
|
+
paimon.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paimon
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "paimon"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A minimal code agent built on litellm + textual"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"litellm>=1.89.4",
|
|
9
|
+
"textual>=8.2.7",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
paimon = "paimon.app:main"
|
|
14
|
+
|
|
15
|
+
[tool.uv]
|
|
16
|
+
package = true
|
|
17
|
+
|
|
18
|
+
[tool.ruff]
|
|
19
|
+
line-length = 120
|
paimon-0.1.0/setup.cfg
ADDED