forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
226 lines
8.0 KiB
226 lines
8.0 KiB
|
3 months ago
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Multi-Agent Pipeline: Process monitoring and log parsing.
|
||
|
|
|
||
|
|
Provides:
|
||
|
|
tail_follow - Follow a file like 'tail -f'
|
||
|
|
get_last_tool - Get last tool call from agent log
|
||
|
|
get_last_message - Get last assistant text from agent log
|
||
|
|
cmd_watch - Watch agent log in real-time
|
||
|
|
cmd_log - Show recent log entries
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from common.log import Colors
|
||
|
|
|
||
|
|
from .status_display import find_agent
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Log Parsing
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def tail_follow(file_path: Path) -> None:
|
||
|
|
"""Follow a file like 'tail -f', cross-platform compatible."""
|
||
|
|
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
||
|
|
# Seek to end of file
|
||
|
|
f.seek(0, 2)
|
||
|
|
|
||
|
|
while True:
|
||
|
|
line = f.readline()
|
||
|
|
if line:
|
||
|
|
print(line, end="", flush=True)
|
||
|
|
else:
|
||
|
|
time.sleep(0.1)
|
||
|
|
|
||
|
|
|
||
|
|
def get_last_tool(log_file: Path, platform: str = "claude") -> str | None:
|
||
|
|
"""Get the last tool call from agent log.
|
||
|
|
|
||
|
|
Supports both Claude Code and OpenCode log formats.
|
||
|
|
|
||
|
|
Claude Code format:
|
||
|
|
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}
|
||
|
|
|
||
|
|
OpenCode format:
|
||
|
|
{"type": "tool_use", "tool": "bash", "state": {"status": "completed"}}
|
||
|
|
"""
|
||
|
|
if not log_file.is_file():
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||
|
|
for line in reversed(lines[-100:]):
|
||
|
|
try:
|
||
|
|
data = json.loads(line)
|
||
|
|
|
||
|
|
if platform == "opencode":
|
||
|
|
# OpenCode format: {"type": "tool_use", "tool": "bash", ...}
|
||
|
|
if data.get("type") == "tool_use":
|
||
|
|
return data.get("tool")
|
||
|
|
else:
|
||
|
|
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
|
||
|
|
if data.get("type") == "assistant":
|
||
|
|
content = data.get("message", {}).get("content", [])
|
||
|
|
for item in content:
|
||
|
|
if item.get("type") == "tool_use":
|
||
|
|
return item.get("name")
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None:
|
||
|
|
"""Get the last assistant text from agent log.
|
||
|
|
|
||
|
|
Supports both Claude Code and OpenCode log formats.
|
||
|
|
|
||
|
|
Claude Code format:
|
||
|
|
{"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}}
|
||
|
|
|
||
|
|
OpenCode format:
|
||
|
|
{"type": "text", "text": "..."}
|
||
|
|
"""
|
||
|
|
if not log_file.is_file():
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||
|
|
for line in reversed(lines[-100:]):
|
||
|
|
try:
|
||
|
|
data = json.loads(line)
|
||
|
|
|
||
|
|
if platform == "opencode":
|
||
|
|
# OpenCode format: {"type": "text", "text": "..."}
|
||
|
|
if data.get("type") == "text":
|
||
|
|
text = data.get("text", "")
|
||
|
|
if text:
|
||
|
|
return text[:max_len]
|
||
|
|
else:
|
||
|
|
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
|
||
|
|
if data.get("type") == "assistant":
|
||
|
|
content = data.get("message", {}).get("content", [])
|
||
|
|
for item in content:
|
||
|
|
if item.get("type") == "text":
|
||
|
|
text = item.get("text", "")
|
||
|
|
if text:
|
||
|
|
return text[:max_len]
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# Commands
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
def cmd_watch(target: str, repo_root: Path) -> int:
|
||
|
|
"""Watch agent log in real-time."""
|
||
|
|
agent = find_agent(target, repo_root)
|
||
|
|
if not agent:
|
||
|
|
print(f"Agent not found: {target}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
worktree = agent.get("worktree_path", "")
|
||
|
|
log_file = Path(worktree) / ".agent-log"
|
||
|
|
|
||
|
|
if not log_file.is_file():
|
||
|
|
print(f"Log file not found: {log_file}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}")
|
||
|
|
print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
try:
|
||
|
|
tail_follow(log_file)
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print() # Clean newline after Ctrl+C
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def cmd_log(target: str, repo_root: Path) -> int:
|
||
|
|
"""Show recent log entries."""
|
||
|
|
agent = find_agent(target, repo_root)
|
||
|
|
if not agent:
|
||
|
|
print(f"Agent not found: {target}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
worktree = agent.get("worktree_path", "")
|
||
|
|
platform = agent.get("platform", "claude")
|
||
|
|
log_file = Path(worktree) / ".agent-log"
|
||
|
|
|
||
|
|
if not log_file.is_file():
|
||
|
|
print(f"Log file not found: {log_file}")
|
||
|
|
return 1
|
||
|
|
|
||
|
|
print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}")
|
||
|
|
print(f"{Colors.DIM}Platform: {platform}{Colors.NC}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
lines = log_file.read_text(encoding="utf-8").splitlines()
|
||
|
|
for line in lines[-50:]:
|
||
|
|
try:
|
||
|
|
data = json.loads(line)
|
||
|
|
msg_type = data.get("type", "")
|
||
|
|
|
||
|
|
if platform == "opencode":
|
||
|
|
# OpenCode format
|
||
|
|
if msg_type == "text":
|
||
|
|
text = data.get("text", "")
|
||
|
|
if text:
|
||
|
|
display = text[:300]
|
||
|
|
if len(text) > 300:
|
||
|
|
display += "..."
|
||
|
|
print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}")
|
||
|
|
elif msg_type == "tool_use":
|
||
|
|
tool_name = data.get("tool", "unknown")
|
||
|
|
status = data.get("state", {}).get("status", "")
|
||
|
|
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})")
|
||
|
|
elif msg_type == "step_start":
|
||
|
|
print(f"{Colors.CYAN}[STEP]{Colors.NC} Start")
|
||
|
|
elif msg_type == "step_finish":
|
||
|
|
reason = data.get("reason", "")
|
||
|
|
print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})")
|
||
|
|
elif msg_type == "error":
|
||
|
|
error_msg = data.get("message", "")
|
||
|
|
print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}")
|
||
|
|
else:
|
||
|
|
# Claude Code format
|
||
|
|
if msg_type == "system":
|
||
|
|
subtype = data.get("subtype", "")
|
||
|
|
print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}")
|
||
|
|
elif msg_type == "user":
|
||
|
|
content = data.get("message", {}).get("content", "")
|
||
|
|
if content:
|
||
|
|
print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}")
|
||
|
|
elif msg_type == "assistant":
|
||
|
|
content = data.get("message", {}).get("content", [])
|
||
|
|
if content:
|
||
|
|
item = content[0]
|
||
|
|
text = item.get("text")
|
||
|
|
tool = item.get("name")
|
||
|
|
if text:
|
||
|
|
display = text[:300]
|
||
|
|
if len(text) > 300:
|
||
|
|
display += "..."
|
||
|
|
print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}")
|
||
|
|
elif tool:
|
||
|
|
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}")
|
||
|
|
elif msg_type == "result":
|
||
|
|
tool_name = data.get("tool", "unknown")
|
||
|
|
print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed")
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
|
||
|
|
return 0
|