376 lines
14 KiB
Python
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())
|