Files
LangBot/plugins/Runner/acp-agent-runner/daemon.py
T
Martin 5aa1b0f5ae 完善 ACPAgentRunner 会话管理与权限边界 (#2822)
* feat(acp-agent-runner): add ACP session commands

* fix(acp-agent-runner): 收紧会话管理契约

* fix(acp-agent-runner): 修复 event-first 会话身份与切换能力校验
2026-10-31 21:53:46 +08:00

477 lines
20 KiB
Python

"""User-side daemon for ACP Runner.
The daemon connects outward to the ACP runner plugin. It is useful when the
LangBot server cannot SSH into the user's workstation. The local ACP process
talks to a localhost MCP server; that server forwards LangBot asset requests
over the already-established WebSocket connection.
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import json
import logging
import os
import shlex
import time
import typing
from pkg.acp_client import AcpError, AcpStdioClient
from pkg.daemon_relay import AgentRuntimeDaemonClient, LocalMCPProxy
from pkg.prompt import acp_prompt_blocks, has_acp_prompt_input, prompt_capabilities
from pkg.session import CLAUDE_ENV_VARS_TO_UNSET, build_session_params
logger = logging.getLogger("langbot-acp-daemon")
def _parse_args(value: typing.Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value if str(item)]
text = str(value).strip()
if not text:
return []
return shlex.split(text)
def _extract_session_id(result: typing.Any) -> str:
if not isinstance(result, dict):
return ""
session_id = result.get("sessionId") or result.get("session_id") or result.get("id")
return str(session_id or "").strip()
def _runtime_has_method(capabilities: dict[str, typing.Any], method: str) -> bool:
if method == "session/load":
return bool(capabilities.get("loadSession"))
if method.startswith("session/"):
session_capabilities = capabilities.get("sessionCapabilities")
if isinstance(session_capabilities, dict):
capability_key = method.split("/", 1)[1].replace("-", "_")
for key in (capability_key, method.split("/", 1)[1]):
if key in session_capabilities:
value = session_capabilities[key]
return value is not None and value is not False
methods = capabilities.get("methods")
if isinstance(methods, list) and method in {str(item) for item in methods}:
return True
key = method.replace("/", "_").replace("-", "_")
value = capabilities.get(method) or capabilities.get(key)
return bool(value)
def _content_text(value: typing.Any) -> str:
if isinstance(value, str):
return value
if not isinstance(value, dict):
return ""
if value.get("type") == "text" and isinstance(value.get("text"), str):
return str(value["text"])
text = value.get("text")
if isinstance(text, str):
return text
return ""
def _agent_text_from_update(update: dict[str, typing.Any]) -> str:
payload = update.get("update") if isinstance(update.get("update"), dict) else update
if not isinstance(payload, dict):
return ""
update_kind = str(payload.get("sessionUpdate") or payload.get("kind") or payload.get("type") or "")
content = payload.get("content")
if update_kind == "agent_message_chunk":
return _content_text(content)
if "agent_message" in update_kind or payload.get("role") == "assistant":
if isinstance(content, list):
return "".join(_content_text(item) for item in content)
return _content_text(content)
return ""
def _agent_message_id(update: dict[str, typing.Any]) -> str:
payload = update.get("update") if isinstance(update.get("update"), dict) else update
if not isinstance(payload, dict):
return ""
return str(payload.get("messageId") or payload.get("message_id") or "")
def _tool_update_payload(update: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
payload = update.get("update") if isinstance(update.get("update"), dict) else update
if not isinstance(payload, dict):
return None
update_kind = str(payload.get("sessionUpdate") or payload.get("kind") or payload.get("type") or "")
if "tool_call" not in update_kind:
return None
return payload
def _tool_update_name(payload: dict[str, typing.Any]) -> str:
metadata = payload.get("_meta")
if isinstance(metadata, dict):
for value in metadata.values():
if isinstance(value, dict) and value.get("toolName"):
return str(value["toolName"])
return str(payload.get("name") or payload.get("title") or "acp_tool")
def _result_event(
result_type: str, data: dict[str, typing.Any], *, sequence: int | None = None
) -> dict[str, typing.Any]:
event = {"type": result_type, "data": data}
if sequence is not None:
event["sequence"] = sequence
return event
class RunnerDaemon(AgentRuntimeDaemonClient):
"""Outbound WebSocket daemon that runs ACP jobs locally."""
async def _run_control_operation(
self,
job_id: str,
operation: str,
payload: dict[str, typing.Any],
) -> None:
config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
arguments = payload.get("arguments") if isinstance(payload.get("arguments"), dict) else {}
command_args = _parse_args(config.get("acp_command"))
if not command_args:
raise AcpError("acp_command is required", code="acp.daemon_config_invalid")
client = AcpStdioClient(
command=command_args[0],
args=command_args[1:],
cwd=str(config.get("workspace") or os.getcwd()),
env={str(k): str(v) for k, v in dict(config.get("env") or {}).items()},
unset_env=(CLAUDE_ENV_VARS_TO_UNSET if str(config.get("provider") or "custom") == "claude-code" else ()),
permission_decision=str(config.get("permission_decision") or "allow_once"),
startup_timeout=float(config.get("startup_timeout") or 30.0),
)
async with client:
initialize_result = await client.initialize(timeout=float(config.get("initialize_timeout") or 30.0))
capabilities = initialize_result.get("agentCapabilities")
if not isinstance(capabilities, dict):
capabilities = {}
timeout = float(config.get("timeout") or 60.0)
if operation == "session.list":
if not _runtime_has_method(capabilities, "session/list"):
raise AcpError("ACP agent does not support session/list", code="acp.session_list_unsupported")
cwd = str(arguments.get("cwd") or config.get("session_cwd") or config.get("workspace") or "")
result = await client.request("session/list", {"cwd": cwd} if cwd else {}, timeout=timeout)
elif operation == "session.delete":
if not _runtime_has_method(capabilities, "session/delete"):
raise AcpError("ACP agent does not support session/delete", code="acp.session_delete_unsupported")
session_id = str(arguments.get("session_id") or "").strip()
if not session_id:
raise AcpError("session_id is required", code="acp.session_id_required")
result = await client.request("session/delete", {"sessionId": session_id}, timeout=timeout)
else:
raise AcpError(f"unsupported control operation: {operation}", code="acp.control_unsupported")
await self.emit_event(job_id, _result_event("control.result", {"operation": operation, "result": result}))
async def run_job(self, job_id: str, payload: dict[str, typing.Any]) -> None:
proxy: LocalMCPProxy | None = None
client: AcpStdioClient | None = None
try:
operation = str(payload.get("operation") or "").strip()
if operation:
await self._run_control_operation(job_id, operation, payload)
return
config = payload.get("config") if isinstance(payload.get("config"), dict) else {}
prompt_text = str(payload.get("prompt_text") or "")
input_data = payload.get("input") if isinstance(payload.get("input"), dict) else {}
if not has_acp_prompt_input(prompt_text, input_data):
raise AcpError("prompt_text is required", code="acp.daemon_empty_prompt")
command_args = _parse_args(config.get("acp_command"))
if not command_args:
raise AcpError("acp_command is required", code="acp.daemon_config_invalid")
mcp_servers = list(config.get("mcp_servers") or [])
if config.get("langbot_assets_enabled", True):
proxy = self.create_mcp_proxy(job_id, request_timeout=float(config.get("mcp_request_timeout") or 60.0))
proxy.start()
mcp_servers.append(proxy.server_config())
client = AcpStdioClient(
command=command_args[0],
args=command_args[1:],
cwd=str(config.get("cwd") or config.get("workspace") or os.getcwd()),
env={str(k): str(v) for k, v in dict(config.get("env") or {}).items()},
unset_env=(
CLAUDE_ENV_VARS_TO_UNSET if str(config.get("provider") or "custom") == "claude-code" else ()
),
permission_decision=str(config.get("permission_decision") or "allow_once"),
startup_timeout=float(config.get("startup_timeout") or 30.0),
)
async with client:
initialize_result = await client.initialize(timeout=float(config.get("initialize_timeout") or 30.0))
capabilities = initialize_result.get("agentCapabilities")
if not isinstance(capabilities, dict):
capabilities = {}
await self.emit_event(
job_id,
_result_event("control.capabilities", {"agent_capabilities": capabilities}),
)
session_id, _created = await self._create_or_resume_session(
client, initialize_result, config, mcp_servers
)
await self.emit_event(
job_id,
_result_event(
"state.updated",
{
"key": "external.acp_session_id",
"value": session_id,
"scope": "conversation",
},
),
)
prompt_blocks = acp_prompt_blocks(prompt_text, input_data, prompt_capabilities(initialize_result))
await self._stream_prompt_results(client, job_id, session_id, prompt_blocks, config)
except asyncio.CancelledError:
await self.emit_event(
job_id,
_result_event(
"run.failed",
{"error": "ACP daemon run cancelled", "code": "acp.daemon_cancelled", "retryable": True},
),
)
return
except TimeoutError:
await self.emit_event(
job_id,
_result_event(
"run.failed",
{"error": "ACP daemon run timed out", "code": "acp.daemon_timeout", "retryable": True},
),
)
except AcpError as exc:
await self.emit_event(
job_id,
_result_event("run.failed", {"error": exc.message, "code": exc.code, "retryable": exc.retryable}),
)
except Exception as exc:
logger.exception("ACP daemon job failed: %s", exc)
await self.emit_event(
job_id,
_result_event("run.failed", {"error": str(exc), "code": "acp.daemon_unexpected"}),
)
finally:
if client and client.stderr_tail.strip():
logger.debug("ACP stderr tail for %s: %s", job_id, client.stderr_tail.strip())
if proxy is not None:
proxy.stop()
async def _create_or_resume_session(
self,
client: AcpStdioClient,
initialize_result: dict[str, typing.Any],
config: dict[str, typing.Any],
mcp_servers: list[dict[str, typing.Any]],
) -> tuple[str, bool]:
capabilities = initialize_result.get("agentCapabilities")
if not isinstance(capabilities, dict):
capabilities = {}
stored_session_id = str(config.get("stored_session_id") or "").strip()
timeout = float(config.get("timeout") or 300.0)
cwd = str(config.get("session_cwd") or config.get("workspace") or os.getcwd())
if stored_session_id and config.get("reuse_session", True):
if _runtime_has_method(capabilities, "session/resume"):
result = await client.request(
"session/resume",
build_session_params(
provider=str(config.get("provider") or "custom"),
session_id=stored_session_id,
cwd=cwd,
mcp_servers=mcp_servers,
),
timeout=timeout,
)
return _extract_session_id(result) or stored_session_id, False
if _runtime_has_method(capabilities, "session/load"):
result = await client.request(
"session/load",
build_session_params(
provider=str(config.get("provider") or "custom"),
session_id=stored_session_id,
cwd=cwd,
mcp_servers=mcp_servers,
),
timeout=timeout,
)
await client.drain_updates()
return _extract_session_id(result) or stored_session_id, False
if not config.get("create_session_if_missing", True):
raise AcpError(
"no stored ACP session and create-session-if-missing is disabled", code="acp.session_missing"
)
result = await client.request(
"session/new",
build_session_params(
provider=str(config.get("provider") or "custom"),
cwd=cwd,
mcp_servers=mcp_servers,
),
timeout=timeout,
)
session_id = _extract_session_id(result)
if not session_id:
raise AcpError(f"ACP session/new did not return a session id: {result!r}", code="acp.response_invalid")
return session_id, True
async def _stream_prompt_results(
self,
client: AcpStdioClient,
job_id: str,
session_id: str,
prompt_blocks: list[dict[str, typing.Any]],
config: dict[str, typing.Any],
) -> None:
prompt_request = client.send_request(
"session/prompt",
{"sessionId": session_id, "prompt": prompt_blocks},
)
sequence = 0
final_text_parts: list[str] = []
active_message_id = ""
active_tool_calls: dict[str, str] = {}
timeout = float(config.get("timeout") or 300.0)
deadline = time.monotonic() + timeout
streaming = bool(config.get("streaming", True))
while True:
if prompt_request.future.done():
update = client.next_update_nowait()
if update is None:
break
else:
if time.monotonic() >= deadline:
raise TimeoutError
update = await client.next_update(timeout=0.1)
if update is None:
continue
text = _agent_text_from_update(update)
if text:
message_id = _agent_message_id(update)
if message_id:
if active_message_id and message_id != active_message_id:
final_text_parts.clear()
active_message_id = message_id
final_text_parts.append(text)
if streaming:
sequence += 1
await self.emit_event(
job_id,
_result_event(
"message.delta",
{
"chunk": {
"role": "assistant",
"content": text,
"all_content": "".join(final_text_parts),
"msg_sequence": sequence,
}
},
sequence=sequence,
),
)
tool_payload = _tool_update_payload(update)
if tool_payload:
await self._emit_tool_update(job_id, tool_payload, active_tool_calls)
await prompt_request.wait(timeout=timeout)
final_text = "".join(final_text_parts).strip()
if not final_text:
await self.emit_event(
job_id,
_result_event(
"run.failed", {"error": "ACP agent returned no assistant text", "code": "acp.empty_response"}
),
)
return
await self.emit_event(
job_id,
_result_event("message.completed", {"message": {"role": "assistant", "content": final_text}}),
)
await self.emit_event(job_id, _result_event("run.completed", {"finish_reason": "stop"}))
async def _emit_tool_update(
self,
job_id: str,
tool_payload: dict[str, typing.Any],
active_tool_calls: dict[str, str],
) -> None:
tool_call_id = str(tool_payload.get("toolCallId") or tool_payload.get("id") or "")
observed_tool_name = _tool_update_name(tool_payload)
status = str(tool_payload.get("status") or "")
if tool_call_id and tool_call_id not in active_tool_calls:
active_tool_calls[tool_call_id] = observed_tool_name
tool_name = observed_tool_name
await self.emit_event(
job_id,
_result_event(
"tool.call.started",
{"tool_call_id": tool_call_id, "tool_name": tool_name, "parameters": {}},
),
)
if tool_call_id and status in {"completed", "failed", "cancelled"}:
tool_name = active_tool_calls.get(tool_call_id, observed_tool_name)
await self.emit_event(
job_id,
_result_event(
"tool.call.completed",
{
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"result": tool_payload if status == "completed" else None,
"error": None if status == "completed" else json.dumps(tool_payload, ensure_ascii=False),
},
),
)
def parse_cli_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Connect a local ACP runtime to LangBot ACP Runner.")
parser.add_argument("--url", required=True, help="Daemon hub WebSocket URL, for example ws://host:8766")
parser.add_argument("--daemon-id", required=True, help="Stable daemon id configured in the LangBot runner.")
parser.add_argument("--token", default=os.environ.get("LANGBOT_ACP_DAEMON_TOKEN", ""), help="Shared hub token.")
parser.add_argument("--reconnect-delay", type=float, default=5.0)
parser.add_argument("--log-level", default="INFO")
return parser.parse_args()
async def async_main() -> None:
args = parse_cli_args()
logging.basicConfig(level=getattr(logging, str(args.log_level).upper(), logging.INFO))
daemon = RunnerDaemon(
url=args.url,
daemon_id=args.daemon_id,
token=args.token,
reconnect_delay=args.reconnect_delay,
)
await daemon.run_forever()
def main() -> int:
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(async_main())
return 0
if __name__ == "__main__":
raise SystemExit(main())