Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ uv run mtv-tui # TUI only

| Subcommand | Description |
|------------|-------------|
| *(default)* / `run` | Start the server + TUI |
| *(default)* / `run [--resume ID]` | Start the server + TUI (optionally resume a saved session) |
| `init [--dir DIR] [--force]` | Bootstrap `~/.mtv-agent/` with config, skills, and commands |
| `config` | Print default `config.json` to stdout |

Expand All @@ -108,7 +108,7 @@ Starts the API server directly (no TUI). Used for development or headless deploy

### `mtv-tui`

Starts only the TUI client, connecting to an already-running server.
Starts only the TUI client, connecting to an already-running server. Accepts `--resume <id>` to resume a saved chat session on startup.

## Configuration

Expand Down Expand Up @@ -255,6 +255,10 @@ Usage: `/command-name [optional context]`
| `Ctrl+C` | Quit |
| `↑` / `↓` | Navigate prompt history |

On exit, the session ID and a `--resume` command are printed to the terminal for easy copy-paste.

Chat history (including tool calls and results) is saved to `~/.mtv-agent/cache/` and fully restored on resume, so the LLM retains context from prior tool interactions.

## Development

```bash
Expand Down
14 changes: 13 additions & 1 deletion mtv_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,15 @@ def _cleanup():

from mtv_agent.tui.app import MTVApp

app = MTVApp(server_url=base_url)
app = MTVApp(server_url=base_url, resume_id=getattr(args, "resume", None))
app.run()

if app.session_id:
sys.stderr.write(
f"\nTo resume this session:\n"
f" mtv-agent run --resume {app.session_id[:8]}\n\n"
)


def _cmd_init(args: argparse.Namespace) -> None:
"""Initialise ~/.mtv-agent/ with config, skills, and commands."""
Expand Down Expand Up @@ -165,6 +171,12 @@ def main():
p_run.add_argument(
"--port", type=int, default=8000, help="Server port (default: 8000)"
)
p_run.add_argument(
"--resume",
metavar="ID",
default=None,
help="Resume a saved chat session by ID (prefix match)",
)
p_run.set_defaults(func=_cmd_run)

# -- init ----------------------------------------------------------------
Expand Down
45 changes: 42 additions & 3 deletions mtv_agent/server/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async def run_stream(
command: str | None = None,
session_id: str | None = None,
max_iterations: int = 20,
max_history_chars: int = 80_000,
max_history_chars: int = 160_000,
) -> AsyncGenerator[dict[str, Any], None]:
"""Run the agent loop, yielding SSE-ready event dicts.

Expand Down Expand Up @@ -89,7 +89,13 @@ async def run_stream(
choice = response.choices[0]

if not choice.message.tool_calls:
yield {"event": "content", "content": choice.message.content or ""}
final_text = choice.message.content or ""
messages.append({"role": "assistant", "content": final_text})
yield {
"event": "_messages_snapshot",
"messages": _prepare_snapshot(messages),
}
yield {"event": "content", "content": final_text}
return

messages.append(choice.message.model_dump())
Expand Down Expand Up @@ -122,7 +128,7 @@ def _build_messages(
command: str | None,
history: list[dict] | None,
user_message: str,
max_history_chars: int = 80_000,
max_history_chars: int = 160_000,
) -> list[dict]:
"""Assemble the initial message list for the LLM."""
msgs: list[dict] = [{"role": "system", "content": system_prompt}]
Expand All @@ -145,3 +151,36 @@ def _parse_args(tc: object) -> dict:
return json.loads(tc.function.arguments)
except json.JSONDecodeError:
return {}


_SNAPSHOT_TOOL_CONTENT_LIMIT = 20_000
_SNAPSHOT_STRIP_KEYS = {"refusal", "annotations", "audio", "function_call"}


def _prepare_snapshot(
messages: list[dict], tool_content_limit: int = _SNAPSHOT_TOOL_CONTENT_LIMIT
) -> list[dict]:
"""Build a persistable snapshot from the agent's messages list.

- Strips the system prompt (messages[0]).
- Truncates tool-result content to *tool_content_limit* chars.
- Removes null-valued metadata fields from model_dump() output.
"""
result: list[dict] = []
for i, msg in enumerate(messages):
if i == 0 and msg.get("role") == "system":
continue
cleaned = {
k: v
for k, v in msg.items()
if k not in _SNAPSHOT_STRIP_KEYS or v is not None
}
Comment on lines +173 to +177

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical logic error: or should be and in the dict comprehension.

The condition if k not in _SNAPSHOT_STRIP_KEYS or v is not None incorrectly keeps keys that should be stripped. For example, if "annotations" has a non-None value, the condition evaluates to False or True = True, so the key is kept—but annotations is in _SNAPSHOT_STRIP_KEYS and should always be removed. Similarly, keys not in strip_keys but with None values are incorrectly kept.

🐛 Proposed fix
         cleaned = {
             k: v
             for k, v in msg.items()
-            if k not in _SNAPSHOT_STRIP_KEYS or v is not None
+            if k not in _SNAPSHOT_STRIP_KEYS and v is not None
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cleaned = {
k: v
for k, v in msg.items()
if k not in _SNAPSHOT_STRIP_KEYS or v is not None
}
cleaned = {
k: v
for k, v in msg.items()
if k not in _SNAPSHOT_STRIP_KEYS and v is not None
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mtv_agent/server/agent.py` around lines 173 - 177, The dict comprehension
that builds cleaned incorrectly uses "or" and should use "and": locate the
comprehension that assigns cleaned from msg (symbols: cleaned, msg,
_SNAPSHOT_STRIP_KEYS) and change the condition from "if k not in
_SNAPSHOT_STRIP_KEYS or v is not None" to "if k not in _SNAPSHOT_STRIP_KEYS and
v is not None" so keys listed in _SNAPSHOT_STRIP_KEYS are always removed while
keeping only non-None values for other keys.

if (
cleaned.get("role") == "tool"
and len(cleaned.get("content") or "") > tool_content_limit
):
cleaned["content"] = (
cleaned["content"][:tool_content_limit] + "\n... (truncated)"
)
result.append(cleaned)
return result
12 changes: 11 additions & 1 deletion mtv_agent/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ async def event_generator():
}
return
event_name = evt.pop("event")
if event_name == "_messages_snapshot":
all_messages = evt["messages"]
continue
if event_name == "content":
assistant_content = evt.get("content", "")
yield {
Expand All @@ -232,7 +235,14 @@ async def event_generator():
_approval_queues.pop(session_id, None)
_cancel_events.pop(session_id, None)
if assistant_content:
all_messages.append({"role": "assistant", "content": assistant_content})
if not any(
m.get("role") == "assistant"
and m.get("content") == assistant_content
for m in all_messages[-1:]
):
all_messages.append(
{"role": "assistant", "content": assistant_content}
)
try:
store.save(session_id, all_messages)
except Exception:
Expand Down
2 changes: 1 addition & 1 deletion mtv_agent/server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class Settings:
commands_dir: str = _BUNDLED_COMMANDS
cache_dir: str = "~/.mtv-agent/cache"
max_iterations: int = 20
max_history_chars: int = 80_000
max_history_chars: int = 160_000
mcp_config: str | None = None
dump_llm: bool = False
dump_dir: str = "~/.mtv-agent/dumps"
Expand Down
58 changes: 49 additions & 9 deletions mtv_agent/server/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import json
import logging
from collections.abc import AsyncGenerator, Callable, Awaitable
from typing import Any
Expand Down Expand Up @@ -64,21 +65,60 @@ async def _safe_call(mcp: MCPManager, name: str, args: dict) -> str:
return f"Error executing tool: {exc}"


def trim_history(history: list[dict], max_chars: int = 80_000) -> list[dict]:
"""Keep only recent history that fits within a character budget."""
def trim_history(history: list[dict], max_chars: int = 160_000) -> list[dict]:
"""Keep only recent history that fits within a character budget.

Messages are grouped so that an assistant message with ``tool_calls`` and
its subsequent ``tool`` messages are treated as an atomic unit -- they are
never split, preventing OpenAI API errors.
"""
groups = _group_messages(history)
total = 0
result: list[dict] = []
for msg in reversed(history):
size = len(msg.get("content", ""))
kept: list[list[dict]] = []
for group in reversed(groups):
size = _group_size(group)
if total + size > max_chars:
break
result.append(msg)
kept.append(group)
total += size
result.reverse()
return result
kept.reverse()
return [msg for group in kept for msg in group]


def _group_messages(messages: list[dict]) -> list[list[dict]]:
"""Group messages into atomic units for trimming.

A tool-call turn (assistant with ``tool_calls`` + following ``tool``
messages) is kept as one group. Everything else is its own group.
"""
groups: list[list[dict]] = []
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "assistant" and msg.get("tool_calls"):
group = [msg]
i += 1
while i < len(messages) and messages[i].get("role") == "tool":
group.append(messages[i])
i += 1
groups.append(group)
else:
groups.append([msg])
i += 1
return groups


def _group_size(group: list[dict]) -> int:
"""Estimate the character size of a message group."""
total = 0
for msg in group:
total += len(msg.get("content") or "")
if msg.get("tool_calls"):
total += len(json.dumps(msg["tool_calls"]))
return total


def _truncate(text: str, limit: int = 80_000) -> str:
def _truncate(text: str, limit: int = 160_000) -> str:
"""Cap tool output to avoid blowing up the LLM context window."""
if len(text) <= limit:
return text
Expand Down
29 changes: 24 additions & 5 deletions mtv_agent/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,18 @@ class MTVApp(App):
("ctrl+l", "clear", "Clear"),
]

def __init__(self, server_url: str = "http://localhost:8000"):
def __init__(
self,
server_url: str = "http://localhost:8000",
resume_id: str | None = None,
):
super().__init__()
self.theme = load_tui_theme()
self.client = AgentClient(server_url)
self.session_id: str | None = None
self._namespace: str | None = None
self._history: list[dict] = []
self._resume_id = resume_id
self._thinking: ThinkingIndicator | None = None
self._current_tool: ToolCard | None = None
self._approval_event: asyncio.Event | None = None
Expand All @@ -86,6 +91,8 @@ def compose(self) -> ComposeResult:
async def on_mount(self) -> None:
self._refresh_status()
self._load_commands()
if self._resume_id:
self._resume_chat(self._resume_id)
self.query_one("#chat-input").focus()

@work(exclusive=True, thread=False)
Expand Down Expand Up @@ -353,10 +360,9 @@ async def _resume_chat(self, chat_id: str) -> None:
content = msg.get("content", "")
if role == "user":
area.mount(UserMessage(content))
self._history.append(msg)
elif role == "assistant":
elif role == "assistant" and content and not msg.get("tool_calls"):
area.mount(AssistantMessage(content))
self._history.append(msg)
self._history.append(msg)

self._update_title()
area.mount(
Expand Down Expand Up @@ -564,12 +570,20 @@ async def action_quit(self) -> None:


def main():
import sys

parser = argparse.ArgumentParser(description="mtv-agent TUI")
parser.add_argument(
"--server",
default="http://localhost:8000",
help="Server URL (default: http://localhost:8000)",
)
parser.add_argument(
"--resume",
metavar="ID",
default=None,
help="Resume a saved chat session by ID (prefix match)",
)
args = parser.parse_args()

log_dir = Path.home() / ".mtv-agent"
Expand All @@ -580,9 +594,14 @@ def main():
filename=str(log_dir / "tui.log"),
filemode="w",
)
app = MTVApp(server_url=args.server)
app = MTVApp(server_url=args.server, resume_id=args.resume)
app.run()

if app.session_id:
sys.stderr.write(
f"\nTo resume this session:\n mtv-tui --resume {app.session_id[:8]}\n\n"
)


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading