Files
2026-07-13 13:04:19 +08:00

376 lines
14 KiB
Python

import io
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Must run BEFORE importing agentmain — it reconfigures stdout at import time,
# and its submodules may print() during init. We capture the raw binary stdout
# for ACP JSON-RPC, then redirect the text-mode stdout to stderr so any stray
# prints from agentmain/llmcore don't pollute the ACP channel.
if sys.platform == "win32":
import msvcrt
_stdout_fd = os.dup(sys.__stdout__.fileno())
msvcrt.setmode(_stdout_fd, os.O_BINARY)
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
# Mark the ACP fd as non-inheritable so child processes can't write to it.
os.set_inheritable(_stdout_fd, False)
# Redirect the original stdout fd to stderr so child processes
# (tool calls) don't write into the ACP JSON-RPC channel.
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
else:
_stdout_fd = os.dup(sys.__stdout__.fileno())
os.set_inheritable(_stdout_fd, False)
_acp_stdout = os.fdopen(_stdout_fd, "wb", buffering=0)
os.dup2(sys.stderr.fileno(), sys.__stdout__.fileno())
class _StdoutToStderrRouter(io.TextIOBase):
"""Redirect text-mode stdout to stderr so agentmain prints don't leak."""
def writable(self): return True
def write(self, s):
if s:
sys.stderr.write(s)
sys.stderr.flush()
return len(s) if s else 0
def flush(self): sys.stderr.flush()
sys.stdout = _StdoutToStderrRouter()
import argparse
import queue
import threading
import traceback
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from agentmain import GeneraticAgent
JSONRPC_VERSION = "2.0"
ACP_PROTOCOL_VERSION = 1
def eprint(*args: Any) -> None:
print(*args, file=sys.stderr, flush=True)
def make_text_block(text: str) -> Dict[str, Any]:
return {"type": "text", "text": text}
def make_session_update(session_id: str, update: Dict[str, Any]) -> Dict[str, Any]:
return {
"jsonrpc": JSONRPC_VERSION,
"method": "session/update",
"params": {"sessionId": session_id, "update": update},
}
def compact_json(obj: Dict[str, Any]) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
def parse_jsonrpc_line(line: str) -> Optional[Dict[str, Any]]:
stripped = line.strip()
if not stripped:
return None
try:
obj = json.loads(stripped)
except json.JSONDecodeError:
return None
return obj if isinstance(obj, dict) else None
def content_blocks_to_text(blocks: List[Dict[str, Any]]) -> str:
parts: List[str] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "text":
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
elif block_type == "resource_link":
name = block.get("name") or "resource"
uri = block.get("uri") or ""
desc = block.get("description") or ""
parts.append(f"[ResourceLink] {name}: {uri}\n{desc}".strip())
elif block_type == "resource":
uri = block.get("uri") or "resource"
text = block.get("text")
if isinstance(text, str) and text:
parts.append(f"[Resource] {uri}\n{text}")
else:
parts.append(f"[Resource] {uri}")
elif block_type == "image":
uri = block.get("uri") or "inline-image"
parts.append(f"[Image omitted] {uri}")
else:
parts.append(f"[Unsupported content block: {block_type}]")
return "\n\n".join(p for p in parts if p).strip()
def jsonrpc_error(code: int, message: str, req_id: Any = None, data: Any = None) -> Dict[str, Any]:
err: Dict[str, Any] = {"code": code, "message": message}
if data is not None:
err["data"] = data
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": err}
def jsonrpc_result(req_id: Any, result: Any) -> Dict[str, Any]:
return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": result}
@dataclass
class SessionState:
session_id: str
cwd: str
agent: GeneraticAgent
current_prompt_id: Any = None
prompt_lock: threading.Lock = field(default_factory=threading.Lock)
class GenericAgentAcpBridge:
def __init__(self, llm_no: int = 0):
self.llm_no = llm_no
self._json_out = _acp_stdout
self._write_lock = threading.Lock()
self._sessions: Dict[str, SessionState] = {}
self._shutdown = False
def write_message(self, msg: Dict[str, Any]) -> None:
payload = compact_json(msg)
raw = (payload + "\n").encode("utf-8")
method = msg.get("method", msg.get("id", "?"))
eprint(f"[ACP-BRIDGE] >>> {payload[:500]}")
try:
with self._write_lock:
self._json_out.write(raw)
self._json_out.flush()
except Exception as e:
eprint(f"[ACP-BRIDGE] WRITE FAILED: {type(e).__name__}: {e}")
def new_agent(self) -> GeneraticAgent:
agent = GeneraticAgent()
agent.next_llm(self.llm_no)
agent.verbose = True
agent.inc_out = True
threading.Thread(target=agent.run, daemon=True).start()
return agent
def handle_initialize(self, req_id: Any, params: Dict[str, Any]) -> None:
requested_version = params.get("protocolVersion", ACP_PROTOCOL_VERSION)
version = ACP_PROTOCOL_VERSION if requested_version == ACP_PROTOCOL_VERSION else ACP_PROTOCOL_VERSION
result = {
"protocolVersion": version,
"agentCapabilities": {
"loadSession": False,
"mcpCapabilities": {"http": False, "sse": False},
"promptCapabilities": {
"image": False,
"audio": False,
"embeddedContext": False,
},
"sessionCapabilities": {},
},
"agentInfo": {
"name": "genericagent-acp",
"title": "GenericAgent",
"version": "0.1.0",
},
"authMethods": [],
}
self.write_message(jsonrpc_result(req_id, result))
def handle_session_new(self, req_id: Any, params: Dict[str, Any]) -> None:
cwd = params.get("cwd")
if not isinstance(cwd, str) or not cwd:
self.write_message(jsonrpc_error(-32602, "cwd is required", req_id))
return
if not os.path.isabs(cwd):
cwd = os.path.abspath(cwd)
session_id = f"ga_{uuid.uuid4().hex}"
agent = self.new_agent()
session = SessionState(session_id=session_id, cwd=cwd, agent=agent)
self._sessions[session_id] = session
self.write_message(
jsonrpc_result(
req_id,
{
"sessionId": session_id,
"modes": None,
"configOptions": None,
},
)
)
def handle_session_prompt(self, req_id: Any, params: Dict[str, Any]) -> None:
session_id = params.get("sessionId")
prompt_blocks = params.get("prompt")
session = self._sessions.get(session_id)
if session is None:
self.write_message(jsonrpc_error(-32602, "unknown sessionId", req_id))
return
if not isinstance(prompt_blocks, list):
self.write_message(jsonrpc_error(-32602, "prompt must be an array", req_id))
return
prompt_text = content_blocks_to_text(prompt_blocks)
if not prompt_text:
self.write_message(jsonrpc_error(-32602, "prompt must contain text or supported content", req_id))
return
with session.prompt_lock:
if session.current_prompt_id is not None:
self.write_message(
jsonrpc_error(-32603, "session already has an active prompt", req_id)
)
return
session.current_prompt_id = req_id
def run_prompt() -> None:
stop_reason = "end_turn"
try:
dq = session.agent.put_task(prompt_text, source="acp")
self._drain_agent_queue(session, dq)
except Exception as exc:
stop_reason = "end_turn"
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(
f"[Bridge error] {type(exc).__name__}: {exc}"
),
},
)
)
eprint("[GenericAgent ACP] prompt thread failed:", traceback.format_exc())
finally:
with session.prompt_lock:
finished_req_id = session.current_prompt_id
session.current_prompt_id = None
if finished_req_id is not None:
import time
time.sleep(0.1)
self.write_message(
jsonrpc_result(finished_req_id, {"stopReason": stop_reason})
)
threading.Thread(target=run_prompt, daemon=True).start()
def _drain_agent_queue(self, session: SessionState, dq: "queue.Queue[Dict[str, Any]]") -> None:
sent_any = False
while True:
item = dq.get()
if not isinstance(item, dict):
continue
# With inc_out=True, "next" items are already incremental deltas.
if "next" in item and "done" not in item:
delta = item["next"]
if isinstance(delta, str) and delta:
sent_any = True
try:
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(delta),
},
)
)
except Exception as e:
eprint(f"[ACP-BRIDGE] ERROR writing update: {e}")
if "done" in item:
# "done" text has post-processing (</summary>\n\n insertion)
# that shifts offsets — cannot safely compute a tail delta.
# Only use "done" content if nothing was streamed (error case).
if not sent_any:
done_text = item["done"]
if isinstance(done_text, str) and done_text:
try:
self.write_message(
make_session_update(
session.session_id,
{
"sessionUpdate": "agent_message_chunk",
"content": make_text_block(done_text),
},
)
)
except Exception as e:
eprint(f"[ACP-BRIDGE] ERROR writing done: {e}")
break
def handle_session_cancel(self, params: Dict[str, Any]) -> None:
session_id = params.get("sessionId")
session = self._sessions.get(session_id)
if session is None:
return
if session.current_prompt_id is not None:
session.agent.abort()
def handle_message(self, msg: Dict[str, Any]) -> None:
method = msg.get("method")
req_id = msg.get("id")
params = msg.get("params") or {}
try:
if method == "initialize":
self.handle_initialize(req_id, params)
elif method == "session/new":
self.handle_session_new(req_id, params)
elif method == "session/prompt":
self.handle_session_prompt(req_id, params)
elif method == "session/cancel":
self.handle_session_cancel(params)
elif method == "session/load":
self.write_message(jsonrpc_error(-32601, "session/load not supported", req_id))
elif method == "session/list":
self.write_message(jsonrpc_error(-32601, "session/list not supported", req_id))
elif method == "session/close":
self.write_message(jsonrpc_result(req_id, {}))
elif method is None:
if req_id is not None:
self.write_message(jsonrpc_error(-32600, "invalid request", req_id))
else:
if req_id is not None:
self.write_message(jsonrpc_error(-32601, f"method not found: {method}", req_id))
except Exception as exc:
eprint("[GenericAgent ACP] request handler failed:", traceback.format_exc())
if req_id is not None:
self.write_message(
jsonrpc_error(-32603, f"internal error: {type(exc).__name__}: {exc}", req_id)
)
def serve(self) -> None:
eprint("[GenericAgent ACP] bridge started")
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stdin, 'buffer') else sys.stdin
for raw_line in stdin:
msg = parse_jsonrpc_line(raw_line)
if msg is None:
continue
self.handle_message(msg)
if self._shutdown:
break
eprint("[GenericAgent ACP] bridge stopped")
def main() -> int:
parser = argparse.ArgumentParser(description="GenericAgent ACP bridge over stdio")
parser.add_argument("--llm-no", type=int, default=0, help="LLM index for GenericAgent")
args = parser.parse_args()
bridge = GenericAgentAcpBridge(llm_no=args.llm_no)
bridge.serve()
return 0
if __name__ == "__main__":
raise SystemExit(main())