mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
feat(agent): isolate terminal task ownership
This commit is contained in:
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any, Literal, Optional
|
||||
@@ -29,6 +29,7 @@ from app.agent.middleware.summarization import (
|
||||
ContextPreservingSummarizationMiddleware,
|
||||
FinalRequestCompactionMiddleware,
|
||||
)
|
||||
from app.agent.middleware.terminal import SubAgentTerminalGrant, subagent_terminal_scope
|
||||
from app.agent.middleware.utils import append_to_system_message
|
||||
from app.agent.middleware.vision import VisionMiddleware
|
||||
from app.agent.policy.contracts import (
|
||||
@@ -81,6 +82,7 @@ Rules:
|
||||
- Give the user only your synthesized final answer and the minimum necessary next step.
|
||||
- If a task requires configuration changes, deletion, adding downloads, adding subscriptions, or any high-impact action, the main agent must handle it directly under the confirmation policy.
|
||||
- Child tools enforce read-only operations. Perform command launches, browser navigation/interactions, and external MCP calls in the main agent; pass the resulting evidence to a child for analysis when useful.
|
||||
- To let a child inspect a parent terminal, declare `terminal_sessions=[{session_id, actions:["read","wait"]}]` on that task. Mentioning a handle in its description does not grant access. Share separately for each batch or pipeline task; process control remains with the parent.
|
||||
</subagents>"""
|
||||
|
||||
SUBAGENT_TASK_DESCRIPTION = (
|
||||
@@ -160,6 +162,10 @@ class _TaskToolInput(BaseModel):
|
||||
default="general-purpose",
|
||||
description="Subagent type to invoke, such as general-purpose or media-researcher",
|
||||
)
|
||||
terminal_sessions: list[SubAgentTerminalGrant] = Field(
|
||||
default_factory=list,
|
||||
description="Explicit parent terminal sessions shared with this child for read-only inspection.",
|
||||
)
|
||||
|
||||
|
||||
class _SubAgentTaskSpec(BaseModel):
|
||||
@@ -170,6 +176,10 @@ class _SubAgentTaskSpec(BaseModel):
|
||||
default="general-purpose",
|
||||
description="Subagent type to invoke, such as general-purpose or media-researcher",
|
||||
)
|
||||
terminal_sessions: list[SubAgentTerminalGrant] = Field(
|
||||
default_factory=list,
|
||||
description="Explicit parent terminal grants for this task only; siblings inherit no grants.",
|
||||
)
|
||||
|
||||
|
||||
class _SubAgentControlInput(BaseModel):
|
||||
@@ -187,6 +197,10 @@ class _SubAgentControlInput(BaseModel):
|
||||
default="general-purpose",
|
||||
description="Single task subagent type for action=start or action=run.",
|
||||
)
|
||||
terminal_sessions: list[SubAgentTerminalGrant] = Field(
|
||||
default_factory=list,
|
||||
description="Terminal grants for a single description; with tasks, set grants separately in each task spec.",
|
||||
)
|
||||
tasks: Optional[list[_SubAgentTaskSpec]] = Field(
|
||||
default=None,
|
||||
description="Batch task specs for action=start or action=run.",
|
||||
@@ -223,6 +237,7 @@ class _SubAgentRuntimeTask:
|
||||
created_at: datetime
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
terminal_sessions: list[SubAgentTerminalGrant] = field(default_factory=list)
|
||||
|
||||
|
||||
def is_subagent_stream_metadata(metadata: Any) -> bool:
|
||||
@@ -494,6 +509,7 @@ class _SubAgentAgentProvider:
|
||||
description: str,
|
||||
subagent_type: Optional[str],
|
||||
task_id: Optional[str] = None,
|
||||
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
|
||||
) -> str:
|
||||
"""调用指定子代理并只返回供主代理读取的结果。"""
|
||||
agent_name, agent = self.get_agent(subagent_type)
|
||||
@@ -503,19 +519,24 @@ class _SubAgentAgentProvider:
|
||||
f"开始调用子代理: subagent_type={agent_name}, task_id={log_task_id}"
|
||||
)
|
||||
try:
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content=description)]},
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": f"subagent-{agent_name}-{thread_suffix}",
|
||||
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
|
||||
async with subagent_terminal_scope(
|
||||
task_id=thread_suffix,
|
||||
user_id=self._policy_context.user_id,
|
||||
terminal_sessions=terminal_sessions,
|
||||
) as terminal_context:
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [HumanMessage(content=description + terminal_context)]},
|
||||
config={
|
||||
"configurable": {
|
||||
"thread_id": f"subagent-{agent_name}-{thread_suffix}",
|
||||
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
|
||||
},
|
||||
"metadata": {
|
||||
"lc_agent_name": agent_name,
|
||||
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
|
||||
},
|
||||
},
|
||||
"metadata": {
|
||||
"lc_agent_name": agent_name,
|
||||
SUBAGENT_STREAM_MARKER_KEY: SUBAGENT_STREAM_MARKER_VALUE,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"子代理调用失败: subagent_type={agent_name}, "
|
||||
@@ -573,11 +594,17 @@ class MoviePilotSubAgentMiddleware(AgentMiddleware):
|
||||
"""懒加载指定名称的子代理图。"""
|
||||
return self._provider.get_agent(agent_name)[1]
|
||||
|
||||
async def _run_task(self, description: str, subagent_type: str) -> str:
|
||||
async def _run_task(
|
||||
self,
|
||||
description: str,
|
||||
subagent_type: str,
|
||||
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
|
||||
) -> str:
|
||||
"""调用指定子代理并只返回供主代理读取的结果。"""
|
||||
return await self._provider.run_task(
|
||||
description=description,
|
||||
subagent_type=subagent_type,
|
||||
terminal_sessions=terminal_sessions,
|
||||
)
|
||||
|
||||
async def awrap_model_call(
|
||||
@@ -768,8 +795,11 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
description: Optional[str],
|
||||
subagent_type: Optional[str],
|
||||
tasks: Optional[list[_SubAgentTaskSpec]],
|
||||
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
|
||||
) -> tuple[list[_SubAgentTaskSpec], Optional[str]]:
|
||||
"""规范化单任务和批量任务输入。"""
|
||||
if tasks and terminal_sessions:
|
||||
return [], "批量或管道任务请在每个 tasks 条目中单独声明 terminal_sessions。"
|
||||
specs = []
|
||||
for task in tasks or []:
|
||||
if isinstance(task, dict):
|
||||
@@ -781,6 +811,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
_SubAgentTaskSpec(
|
||||
description=description,
|
||||
subagent_type=subagent_type or "general-purpose",
|
||||
terminal_sessions=terminal_sessions or [],
|
||||
)
|
||||
)
|
||||
if not specs:
|
||||
@@ -811,6 +842,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
description=record.description,
|
||||
subagent_type=record.subagent_type,
|
||||
task_id=record.task_id,
|
||||
terminal_sessions=record.terminal_sessions,
|
||||
)
|
||||
logger.info(
|
||||
f"异步子代理任务执行完成: task_id={record.task_id}, "
|
||||
@@ -856,6 +888,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
subagent_type=spec.subagent_type or "general-purpose",
|
||||
task=None,
|
||||
created_at=datetime.now(),
|
||||
terminal_sessions=spec.terminal_sessions,
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._execute_managed_task(record),
|
||||
@@ -1043,6 +1076,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
description=description,
|
||||
subagent_type=record.subagent_type,
|
||||
task_id=record.task_id,
|
||||
terminal_sessions=record.terminal_sessions,
|
||||
)
|
||||
logger.info(
|
||||
f"管道子代理任务执行完成: task_id={record.task_id}, "
|
||||
@@ -1074,6 +1108,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
subagent_type=spec.subagent_type or "general-purpose",
|
||||
task=None,
|
||||
created_at=datetime.now(),
|
||||
terminal_sessions=spec.terminal_sessions,
|
||||
)
|
||||
|
||||
def _track_pipeline_task(
|
||||
@@ -1160,6 +1195,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
task_id: Optional[str] = None,
|
||||
wait_mode: str = "all",
|
||||
timeout_ms: Optional[int] = SUBAGENT_DEFAULT_WAIT_TIMEOUT_MS,
|
||||
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
|
||||
) -> str:
|
||||
"""管理异步子代理任务。"""
|
||||
logger.info(f"收到子代理管控操作: action={action}")
|
||||
@@ -1171,6 +1207,7 @@ class SubAgentTaskControlMiddleware(AgentMiddleware):
|
||||
description=description,
|
||||
subagent_type=subagent_type,
|
||||
tasks=tasks,
|
||||
terminal_sessions=terminal_sessions,
|
||||
)
|
||||
if error:
|
||||
logger.info(
|
||||
|
||||
84
app/agent/middleware/terminal.py
Normal file
84
app/agent/middleware/terminal.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""子代理终端只读共享输入与单次调用作用域。"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
close_terminal_scope,
|
||||
current_terminal_scope,
|
||||
require_terminal_scope,
|
||||
)
|
||||
|
||||
|
||||
def _default_actions() -> list[Literal["read", "wait"]]:
|
||||
"""每条授权独立创建动作列表,默认仅可读取或等待父终端。"""
|
||||
return ["read", "wait"]
|
||||
|
||||
|
||||
class SubAgentTerminalGrant(BaseModel): # type: ignore[misc]
|
||||
"""父任务明确授予子代理的单个终端读取能力,不扩大只读策略。"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
session_id: str = Field(..., min_length=1, description="Terminal session owned by the parent task.")
|
||||
actions: list[Literal["read", "wait"]] = Field(
|
||||
default_factory=_default_actions,
|
||||
min_length=1,
|
||||
description="Explicit read-only actions to share; writes and process control cannot be delegated.",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_grants(terminal_sessions: list[SubAgentTerminalGrant]) -> dict[str, frozenset[str]]:
|
||||
"""合并同一父句柄的只读动作,直调入口也执行与工具 schema 相同的校验。"""
|
||||
grants: dict[str, frozenset[str]] = {}
|
||||
for value in terminal_sessions:
|
||||
grant = SubAgentTerminalGrant.model_validate(value)
|
||||
grants[grant.session_id] = grants.get(grant.session_id, frozenset()) | frozenset(grant.actions)
|
||||
return grants
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def subagent_terminal_scope(
|
||||
*,
|
||||
task_id: str,
|
||||
user_id: str,
|
||||
terminal_sessions: Optional[list[SubAgentTerminalGrant]] = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""每次子图调用独立绑定能力;授权失败不调用模型,结束仅撤销子任务能力。"""
|
||||
grants = _normalize_grants(terminal_sessions or [])
|
||||
parent = current_terminal_scope()
|
||||
if parent is not None or grants:
|
||||
parent = require_terminal_scope()
|
||||
child = TerminalScope(
|
||||
user_id=parent.user_id if parent is not None else user_id,
|
||||
task_id=task_id,
|
||||
kind="subagent",
|
||||
)
|
||||
try:
|
||||
if grants:
|
||||
# 无终端的独立子任务无需加载或创建进程级终端管理器。
|
||||
from app.agent.terminal.manager import get_terminal_session_manager
|
||||
|
||||
get_terminal_session_manager().share(require_terminal_scope(), child, grants)
|
||||
context = ""
|
||||
if grants:
|
||||
context = (
|
||||
"\n\n<terminal_sessions>\n"
|
||||
"The host explicitly granted these parent terminals for the listed read-only actions. "
|
||||
"This does not authorize writes, EOF, interrupts, or termination.\n"
|
||||
+ json.dumps([
|
||||
{"session_id": session_id, "actions": sorted(actions)}
|
||||
for session_id, actions in grants.items()
|
||||
], ensure_ascii=False)
|
||||
+ "\n</terminal_sessions>"
|
||||
)
|
||||
with bind_terminal_scope(child):
|
||||
yield context
|
||||
finally:
|
||||
await close_terminal_scope(child)
|
||||
@@ -63,6 +63,12 @@ from app.agent.policy.registry import requests_system_setting_secrets
|
||||
from app.agent.policy.sanitizer import sanitize_for_host
|
||||
from app.agent.prompt import prompt_manager
|
||||
from app.agent.runtime import agent_runtime_manager
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalAccessError,
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
close_terminal_scope,
|
||||
)
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||||
from app.agent.tools.impl.mcp import create_external_mcp_tools
|
||||
@@ -379,6 +385,10 @@ class MoviePilotAgent:
|
||||
"""创建会话 Agent,并保存组合根注入的数据与记忆能力。"""
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
self._terminal_scope = TerminalScope(
|
||||
user_id=user_id or "", task_id=session_id, kind="conversation"
|
||||
)
|
||||
self._scheduled_terminal_scopes: set[TerminalScope] = set()
|
||||
self.channel = channel
|
||||
self.source = source
|
||||
self.username = username
|
||||
@@ -1628,7 +1638,10 @@ class MoviePilotAgent:
|
||||
return tuple(merged)
|
||||
|
||||
def begin_shutdown(self) -> None:
|
||||
"""在任何异步等待前封住当前 Agent 的 detached 子代理提交。"""
|
||||
"""在任何异步等待前封住当前 Agent 的子代理提交和终端作用域。"""
|
||||
self._terminal_scope.seal()
|
||||
for scope in self._scheduled_terminal_scopes:
|
||||
scope.seal()
|
||||
self._shutdown_started = True
|
||||
self._seal_subagent_middleware_instances(self._subagent_middlewares)
|
||||
|
||||
@@ -1996,10 +2009,36 @@ class MoviePilotAgent:
|
||||
images: Optional[List[str]] = None,
|
||||
files: Optional[List[dict[str, Any]]] = None,
|
||||
has_audio_input: bool = False,
|
||||
*,
|
||||
terminal_scope: Optional[TerminalScope] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理用户消息,流式推理并返回 Agent 回复
|
||||
"""
|
||||
"""绑定宿主任务身份覆盖本轮全部推理;正常轮次和图重建保留对话归属。"""
|
||||
scope = terminal_scope or self._terminal_scope
|
||||
if scope.closed or self._shutdown_started or scope.user_id != (self.user_id or ""):
|
||||
raise TerminalAccessError()
|
||||
if scope is not self._terminal_scope:
|
||||
self._scheduled_terminal_scopes.add(scope)
|
||||
with bind_terminal_scope(scope):
|
||||
return await self._process(
|
||||
message, images=images, files=files, has_audio_input=has_audio_input
|
||||
)
|
||||
|
||||
async def release_terminal_scope(self, scope: TerminalScope) -> bool:
|
||||
"""收口宿主临时任务的终端;未真实收敛的作用域留给 Agent 清理重试。"""
|
||||
self._scheduled_terminal_scopes.add(scope)
|
||||
if not await close_terminal_scope(scope):
|
||||
return False
|
||||
self._scheduled_terminal_scopes.discard(scope)
|
||||
return True
|
||||
|
||||
async def _process(
|
||||
self,
|
||||
message: str,
|
||||
images: Optional[List[str]] = None,
|
||||
files: Optional[List[dict[str, Any]]] = None,
|
||||
has_audio_input: bool = False,
|
||||
) -> str:
|
||||
"""在已绑定的宿主任务上下文中流式推理并返回 Agent 回复。"""
|
||||
user_display_saved = False
|
||||
try:
|
||||
logger.info(
|
||||
@@ -2463,11 +2502,16 @@ class MoviePilotAgent:
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""
|
||||
清理智能体资源;detached 子代理未收敛时保留 owner 并返回 False。
|
||||
清理智能体资源;子代理或终端未真实收敛时保留 owner 并返回 False。
|
||||
"""
|
||||
self.begin_shutdown()
|
||||
if not await self._invalidate_cached_agent():
|
||||
logger.error(f"MoviePilot智能体仍有子代理 owner 未收敛: session_id={self.session_id}")
|
||||
children_closed = await self._invalidate_cached_agent()
|
||||
terminals_closed = await close_terminal_scope(self._terminal_scope)
|
||||
for scope in tuple(self._scheduled_terminal_scopes):
|
||||
if not await self.release_terminal_scope(scope):
|
||||
terminals_closed = False
|
||||
if not children_closed or not terminals_closed:
|
||||
logger.error(f"MoviePilot智能体仍有子代理或终端未收敛: session_id={self.session_id}")
|
||||
return False
|
||||
self._pending_secret_confirmation = None
|
||||
self.protected_output_callback = None
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Agent 会话队列、worker 与资源状态的唯一 owner。"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, cast
|
||||
|
||||
from app.agent.contracts import ReplyMode
|
||||
from app.agent.memory import MemoryManager, memory_manager
|
||||
from app.agent.orchestrator import MoviePilotAgent, _SessionUsageSnapshot
|
||||
from app.agent.terminal.ownership import TerminalScope, close_terminal_scope
|
||||
from app.application.agent import AgentDataContext
|
||||
from app.chain.agent import AgentChain
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
@@ -112,6 +113,17 @@ class _MessageTask:
|
||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
|
||||
completion_future: Optional[asyncio.Future[str]] = None
|
||||
enqueued_at: Optional[float] = None
|
||||
scheduled_run_id: Optional[str] = None
|
||||
terminal_scope: Optional[TerminalScope] = field(default=None, init=False)
|
||||
agent: Optional[MoviePilotAgent] = field(default=None, init=False, repr=False)
|
||||
terminal_released: bool = field(default=False, init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""由队列宿主使用持久化运行 ID 装配身份,不向模型暴露可写归属字段。"""
|
||||
if self.scheduled_run_id is not None:
|
||||
self.terminal_scope = TerminalScope(
|
||||
user_id=self.user_id, task_id=self.scheduled_run_id, kind="scheduled"
|
||||
)
|
||||
|
||||
|
||||
class AgentManagerUnavailableError(RuntimeError):
|
||||
@@ -126,6 +138,7 @@ class AgentManagerQueueFullError(RuntimeError):
|
||||
code = "agent_manager_queue_full"
|
||||
|
||||
def __init__(self, session_id: str, limit: int) -> None:
|
||||
"""保存拒绝的会话及容量,供入站边界返回排队失败。"""
|
||||
self.session_id = session_id
|
||||
self.limit = limit
|
||||
super().__init__(
|
||||
@@ -283,6 +296,7 @@ class AgentSessionOwner:
|
||||
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
|
||||
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
|
||||
wait_for_completion: bool = False,
|
||||
scheduled_run_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理用户消息:将消息放入会话队列,按顺序依次处理。
|
||||
@@ -312,6 +326,7 @@ class AgentSessionOwner:
|
||||
agent_factory=agent_factory,
|
||||
agent_setup=agent_setup,
|
||||
completion_future=completion_future,
|
||||
scheduled_run_id=scheduled_run_id,
|
||||
)
|
||||
async with self._lifecycle_lock:
|
||||
if not self._accepting_tasks:
|
||||
@@ -376,9 +391,28 @@ class AgentSessionOwner:
|
||||
)
|
||||
|
||||
if completion_future:
|
||||
return await completion_future
|
||||
try:
|
||||
return await completion_future
|
||||
except asyncio.CancelledError:
|
||||
# 取消等待不能留下仍可执行命令的定时任务;对话 worker 可能仍在收尾。
|
||||
await self._close_scheduled_task_scope(task)
|
||||
raise
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _close_scheduled_task_scope(task: _MessageTask) -> bool:
|
||||
"""取消等待与 worker 收尾共用任务归属,失败时保留已装配 Agent 的重试记录。"""
|
||||
if task.terminal_scope is None or task.terminal_released:
|
||||
return True
|
||||
task.terminal_scope.seal()
|
||||
if task.agent is not None:
|
||||
released = await task.agent.release_terminal_scope(task.terminal_scope)
|
||||
else:
|
||||
released = await close_terminal_scope(task.terminal_scope)
|
||||
if released:
|
||||
task.terminal_released = True
|
||||
return released
|
||||
|
||||
async def _session_worker(self, session_id: str) -> None:
|
||||
"""
|
||||
会话消息处理worker:从队列中逐条取出消息并处理。
|
||||
@@ -405,6 +439,10 @@ class AgentSessionOwner:
|
||||
task_type = _agent_task_metric_type(task.source, task.channel)
|
||||
active_metric_recorded = False
|
||||
try:
|
||||
if task.terminal_scope is not None and task.terminal_scope.closed:
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
task.completion_future.cancel()
|
||||
continue
|
||||
if task.enqueued_at is not None:
|
||||
queue_wait_ms = max(
|
||||
0.0,
|
||||
@@ -426,6 +464,8 @@ class AgentSessionOwner:
|
||||
)
|
||||
active_metric_recorded = True
|
||||
result = await self._process_message_internal(task)
|
||||
if not await self._close_scheduled_task_scope(task):
|
||||
raise AgentManagerUnavailableError("Agent 定时任务仍有终端在停止")
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
if (
|
||||
not self._accepting_tasks
|
||||
@@ -454,8 +494,11 @@ class AgentSessionOwner:
|
||||
-1,
|
||||
task_type=task_type,
|
||||
)
|
||||
await self._finish_task_processing_status(task)
|
||||
queue.task_done()
|
||||
try:
|
||||
await self._close_scheduled_task_scope(task)
|
||||
finally:
|
||||
await self._finish_task_processing_status(task)
|
||||
queue.task_done()
|
||||
if session_id in self._session_cancel_requested:
|
||||
break
|
||||
|
||||
@@ -485,6 +528,8 @@ class AgentSessionOwner:
|
||||
task = queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if task.terminal_scope is not None:
|
||||
task.terminal_scope.seal()
|
||||
if task.completion_future and not task.completion_future.done():
|
||||
if error is None:
|
||||
task.completion_future.cancel()
|
||||
@@ -517,13 +562,18 @@ class AgentSessionOwner:
|
||||
existing_agent = self.active_agents.get(session_id)
|
||||
if (
|
||||
existing_agent
|
||||
and task.agent_factory
|
||||
and isinstance(task.agent_factory, type)
|
||||
and not isinstance(existing_agent, task.agent_factory)
|
||||
and (
|
||||
existing_agent.user_id != task.user_id
|
||||
or (
|
||||
task.agent_factory
|
||||
and isinstance(task.agent_factory, type)
|
||||
and not isinstance(existing_agent, task.agent_factory)
|
||||
)
|
||||
)
|
||||
):
|
||||
if await existing_agent.cleanup() is False:
|
||||
raise AgentManagerUnavailableError(
|
||||
f"Agent 会话 {session_id} 仍有子代理任务在停止"
|
||||
f"Agent 会话 {session_id} 仍有子代理或终端在停止"
|
||||
)
|
||||
self.active_agents.pop(session_id, None)
|
||||
|
||||
@@ -569,7 +619,6 @@ class AgentSessionOwner:
|
||||
self.active_agents[session_id] = agent
|
||||
else:
|
||||
agent = self.active_agents[session_id]
|
||||
agent.user_id = task.user_id
|
||||
# 每条队列任务都携带完整消息上下文,None 也必须覆盖,避免后台任务
|
||||
# 复用会话 Agent 时继续沿用上一条入站消息的渠道。
|
||||
agent.channel = task.channel
|
||||
@@ -588,6 +637,7 @@ class AgentSessionOwner:
|
||||
if task.message_callback is not None and hasattr(agent, "set_message_callback"):
|
||||
agent.set_message_callback(task.message_callback)
|
||||
|
||||
task.agent = agent
|
||||
if task.agent_setup is not None:
|
||||
task.agent_setup(agent)
|
||||
|
||||
@@ -597,13 +647,16 @@ class AgentSessionOwner:
|
||||
}
|
||||
if task.has_audio_input:
|
||||
process_kwargs["has_audio_input"] = True
|
||||
if task.terminal_scope is not None:
|
||||
process_kwargs["terminal_scope"] = task.terminal_scope
|
||||
return await agent.process(task.message, **process_kwargs)
|
||||
|
||||
async def stop_current_task(self, session_id: str) -> bool:
|
||||
"""
|
||||
应急停止当前正在执行的Agent推理任务,但保留会话和记忆。
|
||||
与 clear_session 不同,此方法不会销毁Agent实例或清除记忆,
|
||||
用户可以在停止后继续对话。
|
||||
用户可以在停止后继续对话;已交付的交互对话后台终端保持运行,
|
||||
后续对话仍可读取或显式终止。定时运行的独立终端随该运行收尾。
|
||||
"""
|
||||
async with self._lifecycle_lock:
|
||||
return await self._stop_current_task_locked(session_id)
|
||||
|
||||
@@ -110,6 +110,7 @@ class AgentTaskOwner(AgentLifecycleOwner):
|
||||
reply_mode=ReplyMode.DISPATCH,
|
||||
allow_message_tools=True,
|
||||
wait_for_completion=True,
|
||||
scheduled_run_id=run.run_id,
|
||||
)
|
||||
result_text = str(result or "").strip()
|
||||
success = not result_text.startswith(
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
@@ -14,6 +13,14 @@ from types import ModuleType
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.agent.shell import AgentShell, build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell
|
||||
from app.agent.terminal.output import (
|
||||
TERMINAL_DEFAULT_READ_BYTES,
|
||||
TerminalOutputError,
|
||||
read_payload,
|
||||
resolve_cursor,
|
||||
validate_output_budget,
|
||||
)
|
||||
from app.agent.terminal.ownership import TerminalAccessError, TerminalScope, require_terminal_scope
|
||||
from app.agent.terminal.session import TERMINAL_RETENTION_SECONDS, _TerminalSession
|
||||
from app.agent.tools.impl._command_safety import validate_command_safety
|
||||
from app.runtime.log import logger
|
||||
@@ -31,8 +38,6 @@ else:
|
||||
|
||||
|
||||
TERMINAL_CONCURRENCY_LIMIT = 4
|
||||
TERMINAL_DEFAULT_READ_BYTES = 10 * 1024
|
||||
TERMINAL_MAX_READ_BYTES = 64 * 1024
|
||||
TERMINAL_READ_CHUNK_SIZE = 4096
|
||||
TERMINAL_PTY_POLL_INTERVAL = 0.05
|
||||
TERMINAL_WAIT_DEFAULT_MS = 1000
|
||||
@@ -41,17 +46,7 @@ TERMINAL_YIELD_DEFAULT_MS = 250
|
||||
TERMINAL_YIELD_MAX_MS = 10 * 1000
|
||||
TERMINAL_KILL_GRACE_SECONDS = 3
|
||||
_KILL_SIGNAL = getattr(signal, "SIGKILL", 9)
|
||||
|
||||
|
||||
class TerminalOutputError(ValueError):
|
||||
"""携带稳定错误码和最小页预算的可恢复输出读取错误。"""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "invalid_output_cursor", minimum_read_bytes: Optional[int] = None) -> None:
|
||||
"""保留结构化恢复提示,调用方不必解析中文消息。"""
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.minimum_read_bytes = minimum_read_bytes
|
||||
|
||||
_SHARE_ACTIONS = frozenset({"read", "wait", "write", "interrupt", "kill"})
|
||||
|
||||
class _TerminalSessionManager:
|
||||
"""管理 Agent 后台终端会话的生命周期。"""
|
||||
@@ -65,6 +60,9 @@ class _TerminalSessionManager:
|
||||
self._starting = 0
|
||||
self._starts_idle = asyncio.Event()
|
||||
self._starts_idle.set()
|
||||
self._owner_starts: dict[TerminalScope, int] = {}
|
||||
self._owner_idle: dict[TerminalScope, asyncio.Event] = {}
|
||||
self._grants: dict[TerminalScope, dict[str, frozenset[str]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_bool(value: Any, default: bool = True) -> bool:
|
||||
@@ -120,8 +118,9 @@ class _TerminalSessionManager:
|
||||
login: Optional[bool] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""启动后台命令,在首个输出、完成或首次等待预算到期时交付会话。"""
|
||||
owner = require_terminal_scope()
|
||||
self._validate_command(command, confirmed=confirm_dangerous)
|
||||
self._validate_output_budget(max_output_chars)
|
||||
validate_output_budget(max_output_chars)
|
||||
if since_offset is not None and (type(since_offset) is not int or since_offset != 0):
|
||||
raise TerminalOutputError("新会话的 since_offset 只能为 0 或 null")
|
||||
initial_wait = self._normalize_yield_timeout(yield_time_ms)
|
||||
@@ -131,6 +130,8 @@ class _TerminalSessionManager:
|
||||
should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix"
|
||||
|
||||
async with self._lock:
|
||||
if owner.closed:
|
||||
raise TerminalAccessError()
|
||||
if self._closed:
|
||||
raise RuntimeError("终端会话管理器已关闭")
|
||||
self._cleanup_finished_sessions_locked()
|
||||
@@ -143,33 +144,29 @@ class _TerminalSessionManager:
|
||||
)
|
||||
self._starting += 1
|
||||
self._starts_idle.clear()
|
||||
self._owner_starts[owner] = self._owner_starts.get(owner, 0) + 1
|
||||
self._owner_idle.setdefault(owner, asyncio.Event()).clear()
|
||||
|
||||
session: Optional[_TerminalSession] = None
|
||||
reject_session = False
|
||||
slot_released = False
|
||||
session_released = False
|
||||
try:
|
||||
session = (
|
||||
await self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy)
|
||||
if should_use_pty
|
||||
else await self._start_pipe_session(
|
||||
command, normalized_cwd, normalized_env, shell_policy=shell_policy
|
||||
)
|
||||
launch = asyncio.create_task(
|
||||
self._start_pty_session(command, normalized_cwd, normalized_env, shell_policy=shell_policy)
|
||||
if should_use_pty else self._start_pipe_session(
|
||||
command, normalized_cwd, normalized_env, shell_policy=shell_policy,
|
||||
)
|
||||
|
||||
)
|
||||
try:
|
||||
session = await asyncio.shield(launch)
|
||||
session.owner = owner
|
||||
async with self._lock:
|
||||
reject_session = self._closed
|
||||
if not reject_session:
|
||||
self._sessions[session.session_id] = session
|
||||
self._starting -= 1
|
||||
slot_released = True
|
||||
if self._starting == 0:
|
||||
self._starts_idle.set()
|
||||
|
||||
if reject_session:
|
||||
await self._terminate_session(session)
|
||||
session_released = True
|
||||
raise RuntimeError("终端会话管理器已关闭")
|
||||
if self._closed:
|
||||
raise RuntimeError("终端会话管理器已关闭")
|
||||
if owner.closed:
|
||||
raise TerminalAccessError()
|
||||
self._sessions[session.session_id] = session
|
||||
self._release_start_locked(owner)
|
||||
slot_released = True
|
||||
self._check_access(session, owner, "start")
|
||||
logger.info(
|
||||
f"启动后台终端会话: session_id={session.session_id}, pid={session.pid}, "
|
||||
f"use_pty={session.use_pty}, command={command}"
|
||||
@@ -177,32 +174,59 @@ class _TerminalSessionManager:
|
||||
payload = await self._wait_for_output(
|
||||
session, timeout_ms=initial_wait, since_seq=0, since_offset=since_offset,
|
||||
max_bytes=max_bytes, preserve_output_error=True, max_output_chars=max_output_chars,
|
||||
extra_fields={"yield_time_ms": initial_wait},
|
||||
extra_fields={"yield_time_ms": initial_wait}, scope=owner, action="start",
|
||||
)
|
||||
if self._closed:
|
||||
raise RuntimeError("终端会话管理器已关闭")
|
||||
self._check_access(session, owner, "start")
|
||||
return payload
|
||||
except BaseException:
|
||||
if session is not None and not session_released:
|
||||
cleanup_task = asyncio.create_task(self._discard_started_session(session))
|
||||
cleanup_task = asyncio.create_task(self._recover_started_session(launch, session, owner))
|
||||
while True:
|
||||
try:
|
||||
await asyncio.shield(cleanup_task)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
await cleanup_task
|
||||
# 再次取消调用者也不能把启动预留与未交付的真实进程分离。
|
||||
if cleanup_task.done():
|
||||
break
|
||||
continue
|
||||
raise
|
||||
finally:
|
||||
if not slot_released:
|
||||
async with self._lock:
|
||||
self._starting -= 1
|
||||
if self._starting == 0:
|
||||
self._starts_idle.set()
|
||||
self._release_start_locked(owner)
|
||||
|
||||
async def _recover_started_session(
|
||||
self, launch: asyncio.Task[_TerminalSession], session: Optional[_TerminalSession], owner: TerminalScope,
|
||||
) -> None:
|
||||
"""启动预留持有迟到进程;先回收再取得登记锁,避免取消被登记阻塞。"""
|
||||
if session is None:
|
||||
try:
|
||||
session = await launch
|
||||
except Exception:
|
||||
return
|
||||
session.owner = owner
|
||||
await self._discard_started_session(session)
|
||||
|
||||
def _release_start_locked(self, owner: TerminalScope) -> None:
|
||||
"""同一登记锁中释放全局与任务启动预留,关闭者才可确认快照完整。"""
|
||||
self._starting -= 1
|
||||
if self._starting == 0:
|
||||
self._starts_idle.set()
|
||||
remaining = self._owner_starts[owner] - 1
|
||||
if remaining:
|
||||
self._owner_starts[owner] = remaining
|
||||
else:
|
||||
self._owner_starts.pop(owner)
|
||||
self._owner_idle.pop(owner).set()
|
||||
|
||||
async def _discard_started_session(self, session: _TerminalSession) -> None:
|
||||
"""首次返回前取消时回收无从寻址的进程,并撤销其会话登记。"""
|
||||
await self._terminate_session(session)
|
||||
converged = await self._terminate_session(session)
|
||||
async with self._lock:
|
||||
if self._sessions.get(session.session_id) is session:
|
||||
if converged and self._sessions.get(session.session_id) is session:
|
||||
self._sessions.pop(session.session_id)
|
||||
elif not converged:
|
||||
self._sessions[session.session_id] = session
|
||||
|
||||
async def _start_pty_session(
|
||||
self, command: str, cwd: str, env: dict[str, str], *, shell_policy: Optional[AgentShell] = None,
|
||||
@@ -359,8 +383,8 @@ class _TerminalSessionManager:
|
||||
max_output_chars: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""读取会话当前保留的增量输出。"""
|
||||
session = self.get_session(session_id)
|
||||
return self._read_payload(
|
||||
session, _ = self._get_accessible_session(session_id, "read")
|
||||
return read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, max_output_chars=max_output_chars,
|
||||
)
|
||||
|
||||
@@ -375,11 +399,12 @@ class _TerminalSessionManager:
|
||||
max_output_chars: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""等待未读输出或输出最终收尾;零预算只取快照,不终止后台命令。"""
|
||||
session = self.get_session(session_id)
|
||||
session, scope = self._get_accessible_session(session_id, "wait")
|
||||
normalized_timeout = self._normalize_wait_timeout(timeout_ms)
|
||||
payload = await self._wait_for_output(
|
||||
session, timeout_ms=normalized_timeout, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes,
|
||||
max_output_chars=max_output_chars, extra_fields={"wait_timeout_ms": normalized_timeout},
|
||||
scope=scope, action="wait",
|
||||
)
|
||||
return payload
|
||||
|
||||
@@ -388,13 +413,15 @@ class _TerminalSessionManager:
|
||||
since_offset: Optional[int] = None, max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES,
|
||||
preserve_output_error: bool = False, max_output_chars: Optional[int] = None,
|
||||
extra_fields: Optional[dict[str, Any]] = None,
|
||||
scope: TerminalScope, action: str,
|
||||
) -> dict[str, Any]:
|
||||
"""先捕获通知再查输出,无数据到 await 之间的变化也能唤醒所有等待者。"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout_ms / 1000
|
||||
while True:
|
||||
self._check_access(session, scope, action)
|
||||
changed = session.changed_event
|
||||
payload = self._read_payload(
|
||||
payload = read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes,
|
||||
preserve_output_error=preserve_output_error, max_output_chars=max_output_chars,
|
||||
extra_fields={**(extra_fields or {}), "wait_reason": "completed"},
|
||||
@@ -411,11 +438,23 @@ class _TerminalSessionManager:
|
||||
if remaining <= 0:
|
||||
payload["wait_reason"] = "timeout"
|
||||
return payload
|
||||
try:
|
||||
await asyncio.wait_for(changed.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
# 到期时再取一次一致快照,避免刚到达的数据只体现在高水位里。
|
||||
pass
|
||||
await self._wait_for_change(session, scope, changed, remaining)
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_change(
|
||||
session: _TerminalSession, scope: TerminalScope, changed: asyncio.Event, timeout: float,
|
||||
) -> None:
|
||||
"""输出和双方作用域封口任一变化都结束等待,取消时收齐短命通知任务。"""
|
||||
events = {changed, scope.changed}
|
||||
if session.owner is not None:
|
||||
events.add(session.owner.changed)
|
||||
waiters = [asyncio.create_task(event.wait()) for event in events]
|
||||
try:
|
||||
await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in waiters:
|
||||
task.cancel()
|
||||
await asyncio.gather(*waiters, return_exceptions=True)
|
||||
|
||||
async def write(
|
||||
self, *, session_id: str, input_text: str, since_seq: Optional[int] = None,
|
||||
@@ -423,9 +462,9 @@ class _TerminalSessionManager:
|
||||
max_output_chars: Optional[int] = None, close_stdin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""串行写入输入和可选管道 EOF;关闭 stdin 不影响输出读取器。"""
|
||||
session = self.get_session(session_id)
|
||||
self._validate_output_budget(max_output_chars)
|
||||
self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
session, scope = self._get_accessible_session(session_id, "write")
|
||||
validate_output_budget(max_output_chars)
|
||||
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
if type(close_stdin) is not bool:
|
||||
raise ValueError("close_stdin 必须为布尔值")
|
||||
if close_stdin and session.use_pty:
|
||||
@@ -433,6 +472,7 @@ class _TerminalSessionManager:
|
||||
data = (input_text or "").encode("utf-8")
|
||||
written = 0
|
||||
async with session.input_lock:
|
||||
self._check_access(session, scope, "write")
|
||||
if session.stdin_closed:
|
||||
if data or not close_stdin:
|
||||
raise RuntimeError("会话 stdin 已关闭,不能再写入输入")
|
||||
@@ -440,6 +480,7 @@ class _TerminalSessionManager:
|
||||
raise RuntimeError(f"会话已结束,当前状态: {session.status}")
|
||||
elif session.use_pty:
|
||||
while written < len(data):
|
||||
self._check_access(session, scope, "write")
|
||||
if session.master_fd is None:
|
||||
raise RuntimeError("PTY 已关闭")
|
||||
try:
|
||||
@@ -452,18 +493,20 @@ class _TerminalSessionManager:
|
||||
raise BrokenPipeError("PTY 未接受后续输入")
|
||||
written += count
|
||||
else:
|
||||
await self._write_pipe_input(session, data, close_stdin=close_stdin)
|
||||
await self._write_pipe_input(session, scope, data, close_stdin=close_stdin)
|
||||
written = len(data)
|
||||
|
||||
self._check_access(session, scope, "write")
|
||||
session.updated_at = time.time()
|
||||
payload = self._read_payload(
|
||||
payload = read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
|
||||
max_output_chars=max_output_chars, extra_fields={"written_bytes": written},
|
||||
)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
async def _write_pipe_input(session: _TerminalSession, data: bytes, *, close_stdin: bool) -> None:
|
||||
async def _write_pipe_input(
|
||||
self, session: _TerminalSession, scope: TerminalScope, data: bytes, *, close_stdin: bool,
|
||||
) -> None:
|
||||
"""在会话输入锁内先排空末段,再半关闭并等待写端收尾。"""
|
||||
writer = session.process.stdin if session.process else None
|
||||
if writer is None:
|
||||
@@ -471,7 +514,9 @@ class _TerminalSessionManager:
|
||||
if data:
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
self._check_access(session, scope, "write")
|
||||
if close_stdin:
|
||||
self._check_access(session, scope, "write")
|
||||
writer.close()
|
||||
session.stdin_closed = True
|
||||
await writer.wait_closed()
|
||||
@@ -481,9 +526,9 @@ class _TerminalSessionManager:
|
||||
max_bytes: Optional[int] = TERMINAL_DEFAULT_READ_BYTES, max_output_chars: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""只发送一次真实中断信号,不改变终止意图,也不等待或升级成强杀。"""
|
||||
session = self.get_session(session_id)
|
||||
self._validate_output_budget(max_output_chars)
|
||||
self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
session, _ = self._get_accessible_session(session_id, "interrupt")
|
||||
validate_output_budget(max_output_chars)
|
||||
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
event = getattr(signal, "CTRL_BREAK_EVENT", None)
|
||||
sender = getattr(session.process, "send_signal", None)
|
||||
if os.name != "posix" and (event is None or not callable(sender)):
|
||||
@@ -498,7 +543,7 @@ class _TerminalSessionManager:
|
||||
sent = True
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return self._read_payload(
|
||||
return read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
|
||||
max_output_chars=max_output_chars,
|
||||
extra_fields={"signal": "SIGINT" if os.name == "posix" else "CTRL_BREAK_EVENT", "signal_sent": sent},
|
||||
@@ -515,78 +560,150 @@ class _TerminalSessionManager:
|
||||
max_output_chars: Optional[int] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""向会话进程组发送信号并等待短暂清理。"""
|
||||
session = self.get_session(session_id)
|
||||
self._validate_output_budget(max_output_chars)
|
||||
self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
session, scope = self._get_accessible_session(session_id, "kill")
|
||||
validate_output_budget(max_output_chars)
|
||||
resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
signal_number = self._resolve_signal(sig)
|
||||
if session.status == "running":
|
||||
session.kill_requested = True
|
||||
self._send_signal(session, signal_number)
|
||||
if not await self._wait_for_exit(session):
|
||||
self._check_access(session, scope, "kill")
|
||||
self._send_signal(session, _KILL_SIGNAL)
|
||||
|
||||
return self._read_payload(
|
||||
self._check_access(session, scope, "kill")
|
||||
return read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes, preserve_output_error=True,
|
||||
max_output_chars=max_output_chars,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""停止所有后台终端会话并释放 PTY、读取任务和会话记录。"""
|
||||
"""停止全部会话,未确认进程和读取器收尾的记录保留给下次关闭。"""
|
||||
async with self._close_lock:
|
||||
async with self._lock:
|
||||
self._closed = True
|
||||
for owner in {*self._owner_starts, *(session.owner for session in self._sessions.values())}:
|
||||
if owner is not None:
|
||||
owner.seal()
|
||||
for child in self._grants:
|
||||
child.seal()
|
||||
self._grants.clear()
|
||||
|
||||
await self._starts_idle.wait()
|
||||
|
||||
async with self._lock:
|
||||
sessions = list(self._sessions.values())
|
||||
|
||||
await asyncio.gather(
|
||||
results = await asyncio.gather(
|
||||
*(self._terminate_session(session) for session in sessions),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
for session in sessions:
|
||||
session.close_pty()
|
||||
self._sessions.clear()
|
||||
for session, converged in zip(sessions, results):
|
||||
if converged is True:
|
||||
self._sessions.pop(session.session_id, None)
|
||||
|
||||
async def _terminate_session(self, session: _TerminalSession) -> None:
|
||||
"""以有限等待停止进程,并在必要时升级为 SIGKILL。"""
|
||||
if session.status == "running":
|
||||
session.kill_requested = True
|
||||
self._send_signal(session, signal.SIGTERM)
|
||||
async def close_owner(self, owner: TerminalScope) -> bool:
|
||||
"""先封口并撤销能力,再只收敛本任务的启动和进程;未收敛时保留事实供重试。"""
|
||||
owner.seal()
|
||||
async with self._lock:
|
||||
revoked = set(self._grants.pop(owner, {}))
|
||||
owned = {key for key, session in self._sessions.items() if session.owner is owner}
|
||||
for grants in self._grants.values():
|
||||
for key in owned:
|
||||
grants.pop(key, None)
|
||||
for key in revoked | owned:
|
||||
if key in self._sessions:
|
||||
self._sessions[key].notify_changed()
|
||||
starting = self._owner_idle.get(owner)
|
||||
if starting is not None:
|
||||
try:
|
||||
await asyncio.wait_for(starting.wait(), timeout=TERMINAL_KILL_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
async with self._lock:
|
||||
sessions = [session for session in self._sessions.values() if session.owner is owner]
|
||||
results = await asyncio.gather(
|
||||
*(self._terminate_session(session) for session in sessions), return_exceptions=True,
|
||||
)
|
||||
async with self._lock:
|
||||
for session, converged in zip(sessions, results):
|
||||
if converged is True:
|
||||
self._sessions.pop(session.session_id, None)
|
||||
return not self._owner_starts.get(owner) and not any(
|
||||
session.owner is owner for session in self._sessions.values()
|
||||
)
|
||||
|
||||
if not await self._wait_for_exit(session):
|
||||
self._send_signal(session, _KILL_SIGNAL)
|
||||
async def _terminate_session(self, session: _TerminalSession) -> bool:
|
||||
"""同一终端的关闭串行执行,真实收尾前不丢弃进程或输出读取器。"""
|
||||
async with session.termination_lock:
|
||||
if session.status == "running":
|
||||
session.kill_requested = True
|
||||
self._send_signal(session, signal.SIGTERM)
|
||||
if not await self._wait_for_exit(session):
|
||||
logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}")
|
||||
|
||||
for task in session.reader_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if session.reader_tasks:
|
||||
await asyncio.gather(*session.reader_tasks, return_exceptions=True)
|
||||
session.finish_output()
|
||||
session.close_pty()
|
||||
if session.status == "running":
|
||||
self._send_signal(session, _KILL_SIGNAL)
|
||||
if not await self._wait_for_exit(session):
|
||||
logger.error(f"终端会话关闭超时: session_id={session.session_id}, pid={session.pid}")
|
||||
return False
|
||||
if any(not task.done() for task in session.reader_tasks):
|
||||
return False
|
||||
session.finish_output()
|
||||
session.close_pty()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _wait_for_exit(session: _TerminalSession) -> bool:
|
||||
"""复用终止与关闭的有界等待,超时不取消会话原本的进程收尾任务。"""
|
||||
if session.wait_task is None or session.wait_task.done():
|
||||
return True
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
return True
|
||||
if session.wait_task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(session.wait_task), timeout=TERMINAL_KILL_GRACE_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
except asyncio.CancelledError:
|
||||
if not session.wait_task.cancelled():
|
||||
raise
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return session.status in {"exited", "killed"} and (
|
||||
session.process is None or session.process.returncode is not None
|
||||
)
|
||||
|
||||
def get_session(self, session_id: str) -> _TerminalSession:
|
||||
"""按 ID 获取会话,不存在时抛出清晰错误。"""
|
||||
def _get_accessible_session(self, session_id: str, action: str) -> tuple[_TerminalSession, TerminalScope]:
|
||||
"""所有用户动作共用归属查表,未知句柄与无权访问返回同一最小错误。"""
|
||||
scope = require_terminal_scope()
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
raise KeyError(f"终端会话不存在: {session_id}")
|
||||
return session
|
||||
if session is None:
|
||||
raise TerminalAccessError()
|
||||
self._check_access(session, scope, action)
|
||||
return session, scope
|
||||
|
||||
def _check_access(self, session: _TerminalSession, scope: TerminalScope, action: str) -> None:
|
||||
"""每个可暂停边界重新核对原调用者,封口或撤销后不再泄露输出或继续写入。"""
|
||||
if (
|
||||
self._closed or scope.closed or not scope.user_id or not scope.task_id
|
||||
or session.owner is None or session.owner.closed
|
||||
or self._sessions.get(session.session_id) is not session
|
||||
or (session.owner is not scope and action not in self._grants.get(scope, {}).get(
|
||||
session.session_id, frozenset(),
|
||||
))
|
||||
):
|
||||
raise TerminalAccessError()
|
||||
|
||||
def share(self, parent: TerminalScope, child: TerminalScope, grants: dict[str, frozenset[str]]) -> None:
|
||||
"""宿主原子授予同用户指定任务的有限动作;受授者不能继续转授。"""
|
||||
if (
|
||||
require_terminal_scope() is not parent or parent is child or child.closed
|
||||
or not child.task_id or not child.user_id or parent.user_id != child.user_id
|
||||
):
|
||||
raise TerminalAccessError()
|
||||
for key, actions in grants.items():
|
||||
session, _ = self._get_accessible_session(key, "read")
|
||||
if session.owner is not parent or not isinstance(actions, frozenset) or not actions <= _SHARE_ACTIONS:
|
||||
raise TerminalAccessError()
|
||||
self._grants.setdefault(child, {}).update(grants)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_wait_timeout(timeout_ms: Optional[int]) -> int:
|
||||
@@ -608,159 +725,11 @@ class _TerminalSessionManager:
|
||||
raise ValueError("yield_time_ms 必须为非负整数")
|
||||
return min(yield_time_ms, TERMINAL_YIELD_MAX_MS)
|
||||
|
||||
@staticmethod
|
||||
def _validate_output_budget(max_output_chars: Optional[int]) -> None:
|
||||
"""宿主内部预算必须容纳有界命令预览和完整恢复元数据。"""
|
||||
if max_output_chars is not None and (type(max_output_chars) is not int or max_output_chars < 4096):
|
||||
raise ValueError("max_output_chars 必须为空或至少 4096 的整数")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_read_limit(max_bytes: Optional[int]) -> int:
|
||||
"""限制单次读取返回的输出大小。"""
|
||||
try:
|
||||
normalized = int(max_bytes or TERMINAL_DEFAULT_READ_BYTES)
|
||||
except (TypeError, ValueError):
|
||||
normalized = TERMINAL_DEFAULT_READ_BYTES
|
||||
if normalized <= 0:
|
||||
return TERMINAL_DEFAULT_READ_BYTES
|
||||
return min(normalized, TERMINAL_MAX_READ_BYTES)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_cursor(
|
||||
session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int],
|
||||
) -> tuple[int, int, bool]:
|
||||
"""保留旧序号含义,验证下一分片偏移,并显式恢复已过保留窗口的游标。"""
|
||||
for name, value in (("since_seq", since_seq), ("since_offset", since_offset)):
|
||||
if value is not None and (type(value) is not int or value < 0):
|
||||
raise TerminalOutputError(f"{name} 必须为非负整数")
|
||||
if since_seq is None and since_offset:
|
||||
raise TerminalOutputError("非零 since_offset 必须同时提供 since_seq")
|
||||
seq = session.retained_from_seq - 1 if since_seq is None else since_seq
|
||||
offset = since_offset or 0
|
||||
if seq > session.next_seq - 1:
|
||||
raise TerminalOutputError("since_seq 超过当前输出高水位")
|
||||
if seq < session.retained_from_seq - 1:
|
||||
return session.retained_from_seq - 1, 0, True
|
||||
if not offset:
|
||||
return seq, 0, session.output_lost
|
||||
chunk = next((item for item in session.chunks if item.seq == seq + 1), None)
|
||||
if chunk is None or offset > chunk.byte_size:
|
||||
raise TerminalOutputError("since_offset 超出下一输出分片")
|
||||
try:
|
||||
chunk.text.encode("utf-8")[:offset].decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise TerminalOutputError("since_offset 必须位于完整 UTF-8 字符边界") from error
|
||||
if offset == chunk.byte_size:
|
||||
return chunk.seq, 0, session.output_lost
|
||||
return seq, offset, session.output_lost
|
||||
|
||||
@staticmethod
|
||||
def _slice_output(encoded: bytes, limit: int, *, partial: bool) -> bytes:
|
||||
"""遵守页预算和完整字符边界;零进展明确报错,不能伪装成成功分页。"""
|
||||
if len(encoded) <= limit:
|
||||
return encoded
|
||||
if not partial:
|
||||
raise TerminalOutputError(
|
||||
"当前页无法容纳完整分片;增大 max_bytes 或传 since_offset=0 后继续 read",
|
||||
code="read_limit_too_small", minimum_read_bytes=len(encoded),
|
||||
)
|
||||
text = encoded[:limit].decode("utf-8", errors="ignore")
|
||||
if not text:
|
||||
minimum = len(encoded.decode("utf-8")[0].encode("utf-8"))
|
||||
raise TerminalOutputError(
|
||||
"当前页无法容纳下一个完整 UTF-8 字符;增大 max_bytes 后继续 read",
|
||||
code="read_limit_too_small", minimum_read_bytes=minimum,
|
||||
)
|
||||
return text.encode("utf-8")
|
||||
|
||||
def _collect_output(
|
||||
self,
|
||||
session: _TerminalSession,
|
||||
*,
|
||||
since_seq: Optional[int],
|
||||
since_offset: Optional[int] = None,
|
||||
max_bytes: Optional[int],
|
||||
) -> dict[str, Any]:
|
||||
"""按完整分片序号及下一分片字节偏移返回实际交付的输出页。"""
|
||||
read_limit = self._normalize_read_limit(max_bytes)
|
||||
seq, offset, lost = self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
selected_chunks = [chunk for chunk in session.chunks if chunk.seq > seq]
|
||||
output_parts: list[str] = []
|
||||
output_bytes = 0
|
||||
for chunk in selected_chunks:
|
||||
encoded = chunk.text.encode("utf-8")[offset:]
|
||||
remaining = read_limit - output_bytes
|
||||
if remaining == 0:
|
||||
break
|
||||
try:
|
||||
piece = self._slice_output(encoded, remaining, partial=since_offset is not None)
|
||||
except TerminalOutputError:
|
||||
if not output_parts:
|
||||
raise
|
||||
break
|
||||
output_parts.append(piece.decode("utf-8"))
|
||||
output_bytes += len(piece)
|
||||
if len(piece) < len(encoded):
|
||||
offset += len(piece)
|
||||
break
|
||||
seq, offset = chunk.seq, 0
|
||||
return {
|
||||
"output": "".join(output_parts), "output_until_seq": seq, "output_until_offset": offset,
|
||||
"output_truncated": lost or seq < session.next_seq - 1, "output_lost": lost,
|
||||
}
|
||||
|
||||
def _read_payload(
|
||||
self, session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int],
|
||||
max_bytes: Optional[int], preserve_output_error: bool = False,
|
||||
max_output_chars: Optional[int] = None, extra_fields: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""动作已发生时保留会话句柄和未消费游标,只把分页错误作为附加恢复信息。"""
|
||||
self._validate_output_budget(max_output_chars)
|
||||
try:
|
||||
page = self._collect_output(session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes)
|
||||
except TerminalOutputError as error:
|
||||
if not preserve_output_error or error.code != "read_limit_too_small":
|
||||
raise
|
||||
seq, offset, lost = self._resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
page = {
|
||||
"output": "", "output_until_seq": seq, "output_until_offset": offset,
|
||||
"output_truncated": True, "output_lost": lost,
|
||||
"output_error": {
|
||||
"code": error.code, "message": str(error), "minimum_read_bytes": error.minimum_read_bytes,
|
||||
},
|
||||
}
|
||||
payload = {**self._session_payload(session, page), **(extra_fields or {})}
|
||||
if max_output_chars is None or len(json.dumps(payload, ensure_ascii=False, indent=2)) <= max_output_chars:
|
||||
return payload
|
||||
low, high = 1, self._normalize_read_limit(max_bytes) - 1
|
||||
best = None
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
try:
|
||||
candidate = self._read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=middle, extra_fields=extra_fields,
|
||||
)
|
||||
except TerminalOutputError:
|
||||
low = middle + 1
|
||||
continue
|
||||
if len(json.dumps(candidate, ensure_ascii=False, indent=2)) <= max_output_chars:
|
||||
best, low = candidate, middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
if best is not None:
|
||||
return best
|
||||
budget_error = TerminalOutputError(
|
||||
"Agent 结果预算无法容纳完整分片;传 since_offset=0 并调整 max_bytes 后继续 read,勿重复执行动作",
|
||||
code="read_limit_too_small",
|
||||
)
|
||||
if not preserve_output_error:
|
||||
raise budget_error
|
||||
payload = self._read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=1,
|
||||
preserve_output_error=True, extra_fields=extra_fields,
|
||||
)
|
||||
payload["output_error"]["message"] = str(budget_error)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _resolve_signal(sig: Optional[str | int]) -> int:
|
||||
@@ -814,54 +783,7 @@ class _TerminalSessionManager:
|
||||
session = self._sessions.pop(session_id)
|
||||
session.close_pty()
|
||||
|
||||
@staticmethod
|
||||
def _text_preview(value: str, limit: int = 1024) -> str:
|
||||
"""按 JSON 实际转义开销限制元数据预览,极长命令不能挤掉输出游标。"""
|
||||
low, high = 0, min(len(value), limit)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if len(json.dumps(value[:middle], ensure_ascii=False)) <= limit:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
return value[:low]
|
||||
|
||||
@staticmethod
|
||||
def _session_payload(
|
||||
session: _TerminalSession,
|
||||
page: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""生成工具返回的结构化会话状态。"""
|
||||
command = _TerminalSessionManager._text_preview(session.command)
|
||||
cwd = _TerminalSessionManager._text_preview(session.cwd)
|
||||
error = _TerminalSessionManager._text_preview(session.error, 512) if session.error else session.error
|
||||
shell = session.shell_policy.executable if session.shell_policy else None
|
||||
shell_preview = _TerminalSessionManager._text_preview(shell, 256) if shell else shell
|
||||
if session.status == "running":
|
||||
outcome = "pending"
|
||||
elif session.status == "error":
|
||||
outcome = "failed"
|
||||
elif session.exit_code is None:
|
||||
outcome = "unknown"
|
||||
else:
|
||||
outcome = "succeeded" if session.exit_code == 0 and session.status != "killed" else "failed"
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"command": command, "command_truncated": command != session.command,
|
||||
"command_total_chars": len(session.command), "cwd": cwd, "cwd_truncated": cwd != session.cwd,
|
||||
"pid": session.pid,
|
||||
"status": session.status,
|
||||
"exit_code": session.exit_code,
|
||||
"execution_outcome": outcome,
|
||||
"use_pty": session.use_pty,
|
||||
"shell": shell_preview, "shell_truncated": shell_preview != shell,
|
||||
"login": session.shell_policy.login if session.shell_policy else None, "stdin_closed": session.stdin_closed,
|
||||
"last_seq": session.next_seq - 1,
|
||||
"retained_from_seq": session.retained_from_seq,
|
||||
"output_complete": session.output_complete,
|
||||
"error": error, "error_truncated": error != session.error,
|
||||
**page,
|
||||
}
|
||||
|
||||
|
||||
terminal_session_manager = _TerminalSessionManager()
|
||||
@@ -870,6 +792,9 @@ terminal_session_manager = _TerminalSessionManager()
|
||||
def get_terminal_session_manager() -> _TerminalSessionManager:
|
||||
"""返回当前进程的终端会话管理器,避免复用已完成关停的实例。"""
|
||||
global terminal_session_manager
|
||||
if terminal_session_manager._closed:
|
||||
if (
|
||||
terminal_session_manager._closed and not terminal_session_manager._sessions
|
||||
and terminal_session_manager._starting == 0
|
||||
):
|
||||
terminal_session_manager = _TerminalSessionManager()
|
||||
return terminal_session_manager
|
||||
|
||||
231
app/agent/terminal/output.py
Normal file
231
app/agent/terminal/output.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""终端输出分页、游标校验与有界结果投影。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
|
||||
TERMINAL_DEFAULT_READ_BYTES = 10 * 1024
|
||||
TERMINAL_MAX_READ_BYTES = 64 * 1024
|
||||
|
||||
class TerminalOutputError(ValueError):
|
||||
"""携带稳定错误码和最小页预算的可恢复输出读取错误。"""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "invalid_output_cursor", minimum_read_bytes: Optional[int] = None) -> None:
|
||||
"""保留结构化恢复提示,调用方不必解析中文消息。"""
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.minimum_read_bytes = minimum_read_bytes
|
||||
|
||||
|
||||
|
||||
def validate_output_budget(max_output_chars: Optional[int]) -> None:
|
||||
"""宿主内部预算必须容纳有界命令预览和完整恢复元数据。"""
|
||||
if max_output_chars is not None and (type(max_output_chars) is not int or max_output_chars < 4096):
|
||||
raise ValueError("max_output_chars 必须为空或至少 4096 的整数")
|
||||
|
||||
|
||||
|
||||
def normalize_read_limit(max_bytes: Optional[int]) -> int:
|
||||
"""限制单次读取返回的输出大小。"""
|
||||
try:
|
||||
normalized = int(max_bytes or TERMINAL_DEFAULT_READ_BYTES)
|
||||
except (TypeError, ValueError):
|
||||
normalized = TERMINAL_DEFAULT_READ_BYTES
|
||||
if normalized <= 0:
|
||||
return TERMINAL_DEFAULT_READ_BYTES
|
||||
return min(normalized, TERMINAL_MAX_READ_BYTES)
|
||||
|
||||
|
||||
|
||||
def resolve_cursor(
|
||||
session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int],
|
||||
) -> tuple[int, int, bool]:
|
||||
"""保留旧序号含义,验证下一分片偏移,并显式恢复已过保留窗口的游标。"""
|
||||
for name, value in (("since_seq", since_seq), ("since_offset", since_offset)):
|
||||
if value is not None and (type(value) is not int or value < 0):
|
||||
raise TerminalOutputError(f"{name} 必须为非负整数")
|
||||
if since_seq is None and since_offset:
|
||||
raise TerminalOutputError("非零 since_offset 必须同时提供 since_seq")
|
||||
seq = session.retained_from_seq - 1 if since_seq is None else since_seq
|
||||
offset = since_offset or 0
|
||||
if seq > session.next_seq - 1:
|
||||
raise TerminalOutputError("since_seq 超过当前输出高水位")
|
||||
if seq < session.retained_from_seq - 1:
|
||||
return session.retained_from_seq - 1, 0, True
|
||||
if not offset:
|
||||
return seq, 0, session.output_lost
|
||||
chunk = next((item for item in session.chunks if item.seq == seq + 1), None)
|
||||
if chunk is None or offset > chunk.byte_size:
|
||||
raise TerminalOutputError("since_offset 超出下一输出分片")
|
||||
try:
|
||||
chunk.text.encode("utf-8")[:offset].decode("utf-8")
|
||||
except UnicodeDecodeError as error:
|
||||
raise TerminalOutputError("since_offset 必须位于完整 UTF-8 字符边界") from error
|
||||
if offset == chunk.byte_size:
|
||||
return chunk.seq, 0, session.output_lost
|
||||
return seq, offset, session.output_lost
|
||||
|
||||
|
||||
|
||||
def _slice_output(encoded: bytes, limit: int, *, partial: bool) -> bytes:
|
||||
"""遵守页预算和完整字符边界;零进展明确报错,不能伪装成成功分页。"""
|
||||
if len(encoded) <= limit:
|
||||
return encoded
|
||||
if not partial:
|
||||
raise TerminalOutputError(
|
||||
"当前页无法容纳完整分片;增大 max_bytes 或传 since_offset=0 后继续 read",
|
||||
code="read_limit_too_small", minimum_read_bytes=len(encoded),
|
||||
)
|
||||
text = encoded[:limit].decode("utf-8", errors="ignore")
|
||||
if not text:
|
||||
minimum = len(encoded.decode("utf-8")[0].encode("utf-8"))
|
||||
raise TerminalOutputError(
|
||||
"当前页无法容纳下一个完整 UTF-8 字符;增大 max_bytes 后继续 read",
|
||||
code="read_limit_too_small", minimum_read_bytes=minimum,
|
||||
)
|
||||
return text.encode("utf-8")
|
||||
|
||||
|
||||
|
||||
def collect_output(
|
||||
session: _TerminalSession,
|
||||
*,
|
||||
since_seq: Optional[int],
|
||||
since_offset: Optional[int] = None,
|
||||
max_bytes: Optional[int],
|
||||
) -> dict[str, Any]:
|
||||
"""按完整分片序号及下一分片字节偏移返回实际交付的输出页。"""
|
||||
read_limit = normalize_read_limit(max_bytes)
|
||||
seq, offset, lost = resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
selected_chunks = [chunk for chunk in session.chunks if chunk.seq > seq]
|
||||
output_parts: list[str] = []
|
||||
output_bytes = 0
|
||||
for chunk in selected_chunks:
|
||||
encoded = chunk.text.encode("utf-8")[offset:]
|
||||
remaining = read_limit - output_bytes
|
||||
if remaining == 0:
|
||||
break
|
||||
try:
|
||||
piece = _slice_output(encoded, remaining, partial=since_offset is not None)
|
||||
except TerminalOutputError:
|
||||
if not output_parts:
|
||||
raise
|
||||
break
|
||||
output_parts.append(piece.decode("utf-8"))
|
||||
output_bytes += len(piece)
|
||||
if len(piece) < len(encoded):
|
||||
offset += len(piece)
|
||||
break
|
||||
seq, offset = chunk.seq, 0
|
||||
return {
|
||||
"output": "".join(output_parts), "output_until_seq": seq, "output_until_offset": offset,
|
||||
"output_truncated": lost or seq < session.next_seq - 1, "output_lost": lost,
|
||||
}
|
||||
|
||||
|
||||
def read_payload(
|
||||
session: _TerminalSession, *, since_seq: Optional[int], since_offset: Optional[int],
|
||||
max_bytes: Optional[int], preserve_output_error: bool = False,
|
||||
max_output_chars: Optional[int] = None, extra_fields: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""动作已发生时保留会话句柄和未消费游标,只把分页错误作为附加恢复信息。"""
|
||||
validate_output_budget(max_output_chars)
|
||||
try:
|
||||
page = collect_output(session, since_seq=since_seq, since_offset=since_offset, max_bytes=max_bytes)
|
||||
except TerminalOutputError as error:
|
||||
if not preserve_output_error or error.code != "read_limit_too_small":
|
||||
raise
|
||||
seq, offset, lost = resolve_cursor(session, since_seq=since_seq, since_offset=since_offset)
|
||||
page = {
|
||||
"output": "", "output_until_seq": seq, "output_until_offset": offset,
|
||||
"output_truncated": True, "output_lost": lost,
|
||||
"output_error": {
|
||||
"code": error.code, "message": str(error), "minimum_read_bytes": error.minimum_read_bytes,
|
||||
},
|
||||
}
|
||||
payload = {**_session_payload(session, page), **(extra_fields or {})}
|
||||
if max_output_chars is None or len(json.dumps(payload, ensure_ascii=False, indent=2)) <= max_output_chars:
|
||||
return payload
|
||||
low, high = 1, normalize_read_limit(max_bytes) - 1
|
||||
best = None
|
||||
while low <= high:
|
||||
middle = (low + high) // 2
|
||||
try:
|
||||
candidate = read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=middle, extra_fields=extra_fields,
|
||||
)
|
||||
except TerminalOutputError:
|
||||
low = middle + 1
|
||||
continue
|
||||
if len(json.dumps(candidate, ensure_ascii=False, indent=2)) <= max_output_chars:
|
||||
best, low = candidate, middle + 1
|
||||
else:
|
||||
high = middle - 1
|
||||
if best is not None:
|
||||
return best
|
||||
budget_error = TerminalOutputError(
|
||||
"Agent 结果预算无法容纳完整分片;传 since_offset=0 并调整 max_bytes 后继续 read,勿重复执行动作",
|
||||
code="read_limit_too_small",
|
||||
)
|
||||
if not preserve_output_error:
|
||||
raise budget_error
|
||||
payload = read_payload(
|
||||
session, since_seq=since_seq, since_offset=since_offset, max_bytes=1,
|
||||
preserve_output_error=True, extra_fields=extra_fields,
|
||||
)
|
||||
payload["output_error"]["message"] = str(budget_error)
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
def _text_preview(value: str, limit: int = 1024) -> str:
|
||||
"""按 JSON 实际转义开销限制元数据预览,极长命令不能挤掉输出游标。"""
|
||||
low, high = 0, min(len(value), limit)
|
||||
while low < high:
|
||||
middle = (low + high + 1) // 2
|
||||
if len(json.dumps(value[:middle], ensure_ascii=False)) <= limit:
|
||||
low = middle
|
||||
else:
|
||||
high = middle - 1
|
||||
return value[:low]
|
||||
|
||||
|
||||
|
||||
def _session_payload(
|
||||
session: _TerminalSession,
|
||||
page: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""生成工具返回的结构化会话状态。"""
|
||||
command = _text_preview(session.command)
|
||||
cwd = _text_preview(session.cwd)
|
||||
error = _text_preview(session.error, 512) if session.error else session.error
|
||||
shell = session.shell_policy.executable if session.shell_policy else None
|
||||
shell_preview = _text_preview(shell, 256) if shell else shell
|
||||
if session.status == "running":
|
||||
outcome = "pending"
|
||||
elif session.status == "error":
|
||||
outcome = "failed"
|
||||
elif session.exit_code is None:
|
||||
outcome = "unknown"
|
||||
else:
|
||||
outcome = "succeeded" if session.exit_code == 0 and session.status != "killed" else "failed"
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"command": command, "command_truncated": command != session.command,
|
||||
"command_total_chars": len(session.command), "cwd": cwd, "cwd_truncated": cwd != session.cwd,
|
||||
"pid": session.pid,
|
||||
"status": session.status,
|
||||
"exit_code": session.exit_code,
|
||||
"execution_outcome": outcome,
|
||||
"use_pty": session.use_pty,
|
||||
"shell": shell_preview, "shell_truncated": shell_preview != shell,
|
||||
"login": session.shell_policy.login if session.shell_policy else None, "stdin_closed": session.stdin_closed,
|
||||
"last_seq": session.next_seq - 1,
|
||||
"retained_from_seq": session.retained_from_seq,
|
||||
"output_complete": session.output_complete,
|
||||
"error": error, "error_truncated": error != session.error,
|
||||
**page,
|
||||
}
|
||||
105
app/agent/terminal/ownership.py
Normal file
105
app/agent/terminal/ownership.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""由宿主持有的终端任务身份及异步调用上下文,不接受模型声明归属。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
TERMINAL_SCOPE_CLOSE_TIMEOUT_SECONDS = 6.0
|
||||
|
||||
|
||||
class TerminalAccessError(RuntimeError):
|
||||
"""以相同错误隐藏不存在、失效或不属于当前任务的终端记录。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""固定消息不携带终端命令、输出、进程号或其他任务身份。"""
|
||||
super().__init__("当前任务无法访问此终端;请核对任务实际状态,勿自动重跑命令")
|
||||
|
||||
|
||||
@dataclass(eq=False, frozen=True, slots=True)
|
||||
class TerminalScope:
|
||||
"""用宿主对象身份区分同名任务的不同代次,关闭后不能重新激活。"""
|
||||
|
||||
user_id: str
|
||||
task_id: str
|
||||
kind: str
|
||||
_closed: bool = field(default=False, init=False, repr=False)
|
||||
changed: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
||||
_active_runs: int = field(default=0, init=False, repr=False)
|
||||
runs_idle: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""初始化一次性命令的空闲边界,供作用域收口等待真实进程结束。"""
|
||||
self.runs_idle.set()
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""公开只读封口事实,普通模型参数不能重新打开同一任务。"""
|
||||
return self._closed
|
||||
|
||||
def seal(self) -> None:
|
||||
"""在任何异步清理之前同步封口,并唤醒等待撤销的调用。"""
|
||||
object.__setattr__(self, "_closed", True)
|
||||
self.changed.set()
|
||||
|
||||
def begin_run(self) -> None:
|
||||
"""登记一次性命令,即使尚未取得并发槽也不能被作用域清理遗漏。"""
|
||||
if self.closed:
|
||||
raise TerminalAccessError()
|
||||
object.__setattr__(self, "_active_runs", self._active_runs + 1)
|
||||
self.runs_idle.clear()
|
||||
|
||||
def finish_run(self) -> None:
|
||||
"""发布一次性命令已经完成,允许作用域收口返回真实终态。"""
|
||||
remaining = max(0, self._active_runs - 1)
|
||||
object.__setattr__(self, "_active_runs", remaining)
|
||||
if remaining == 0:
|
||||
self.runs_idle.set()
|
||||
|
||||
async def wait_runs(self) -> bool:
|
||||
"""等待作用域下的 run 全部退出,超出有界回收时间则保留未收敛事实。"""
|
||||
try:
|
||||
await asyncio.wait_for(self.runs_idle.wait(), timeout=TERMINAL_SCOPE_CLOSE_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
return self._active_runs == 0
|
||||
|
||||
|
||||
_terminal_scope: ContextVar[Optional[TerminalScope]] = ContextVar("agent_terminal_scope", default=None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_terminal_scope(scope: Optional[TerminalScope]) -> Iterator[None]:
|
||||
"""只在当前异步调用链绑定宿主身份,异常和嵌套调用始终还原上层身份。"""
|
||||
token = _terminal_scope.set(scope)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_terminal_scope.reset(token)
|
||||
|
||||
|
||||
def current_terminal_scope() -> Optional[TerminalScope]:
|
||||
"""供宿主查看当前绑定对象,不创建默认用户或全局任务。"""
|
||||
return _terminal_scope.get()
|
||||
|
||||
|
||||
def require_terminal_scope() -> TerminalScope:
|
||||
"""缺少可信主体、任务身份或已经封口时拒绝进入终端能力。"""
|
||||
scope = current_terminal_scope()
|
||||
if scope is None or scope.closed or not scope.user_id or not scope.task_id:
|
||||
raise TerminalAccessError()
|
||||
return scope
|
||||
|
||||
|
||||
async def close_terminal_scope(scope: TerminalScope) -> bool:
|
||||
"""只收敛已装配的终端管理器,无终端任务封口时不物化进程能力。"""
|
||||
scope.seal()
|
||||
module = sys.modules.get("app.agent.terminal.manager")
|
||||
manager = getattr(module, "terminal_session_manager", None)
|
||||
manager_closed = True if manager is None else bool(await manager.close_owner(scope))
|
||||
return manager_closed and await scope.wait_runs()
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.agent.shell import AgentShell
|
||||
from app.agent.terminal.ownership import TerminalScope
|
||||
|
||||
TERMINAL_RETENTION_SECONDS = 30 * 60
|
||||
TERMINAL_MAX_RETAINED_BYTES = 1024 * 1024
|
||||
@@ -36,8 +37,10 @@ class _TerminalSession:
|
||||
pid: int
|
||||
use_pty: bool
|
||||
shell_policy: Optional[AgentShell] = None
|
||||
owner: Optional[TerminalScope] = None
|
||||
stdin_closed: bool = False
|
||||
input_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
termination_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
status: str = "running"
|
||||
|
||||
@@ -17,13 +17,12 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.agent.shell import build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell
|
||||
from app.agent.terminal.manager import (
|
||||
TERMINAL_DEFAULT_READ_BYTES,
|
||||
TERMINAL_MAX_READ_BYTES,
|
||||
TERMINAL_WAIT_DEFAULT_MS,
|
||||
TERMINAL_YIELD_DEFAULT_MS,
|
||||
TerminalOutputError,
|
||||
get_terminal_session_manager,
|
||||
)
|
||||
from app.agent.terminal.output import TERMINAL_DEFAULT_READ_BYTES, TERMINAL_MAX_READ_BYTES, TerminalOutputError
|
||||
from app.agent.terminal.ownership import TerminalAccessError, TerminalScope, require_terminal_scope
|
||||
from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS, MoviePilotTool
|
||||
from app.agent.tools.impl._command_safety import validate_command_safety
|
||||
from app.agent.tools.tags import ToolTag
|
||||
@@ -479,9 +478,12 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
cwd: Optional[str] = None,
|
||||
shell: Optional[str] = None,
|
||||
login: bool = False,
|
||||
scope_cancelled: bool = False,
|
||||
) -> str:
|
||||
"""分开返回机器可判定的执行状态与有界输出,不能靠完成提示推断成功。"""
|
||||
if exit_code is None:
|
||||
if scope_cancelled:
|
||||
result = "命令因任务作用域关闭而取消,未确认业务动作是否完成"
|
||||
elif exit_code is None:
|
||||
result = "无法确认命令进程已结束,请先核对实际状态"
|
||||
elif timed_out:
|
||||
result = f"命令执行超时 (限制: {timeout}秒,已终止进程)"
|
||||
@@ -503,11 +505,15 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
result += "\n\n...(仅展示前后各 16KB 内容)"
|
||||
if not output.combined_preview:
|
||||
result += "\n\n(无输出内容)"
|
||||
succeeded = exit_code == 0 and not timed_out
|
||||
outcome = "unknown" if exit_code is None else ("succeeded" if succeeded else "failed")
|
||||
succeeded = exit_code == 0 and not timed_out and not scope_cancelled
|
||||
outcome = "failed" if scope_cancelled else (
|
||||
"unknown" if exit_code is None else ("succeeded" if succeeded else "failed")
|
||||
)
|
||||
return ExecuteCommandTool._dump({
|
||||
"action": "run", "success": succeeded, "execution_outcome": outcome,
|
||||
"status": "unknown" if exit_code is None else ("timed_out" if timed_out else "exited"),
|
||||
"status": "cancelled" if scope_cancelled else (
|
||||
"unknown" if exit_code is None else ("timed_out" if timed_out else "exited")
|
||||
),
|
||||
"exit_code": exit_code, "timed_out": timed_out, "timeout": timeout,
|
||||
"cwd": cwd, "shell": shell, "login": login, "stdin_closed": True,
|
||||
"output_truncated": output.preview_truncated, "output_file": output.temp_file_path,
|
||||
@@ -527,12 +533,69 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
) -> str:
|
||||
"""一次性执行命令并返回结构化终态;退出路径都必须释放读取任务和归档句柄。"""
|
||||
self._validate_command(command, confirmed=confirm_dangerous)
|
||||
scope = require_terminal_scope()
|
||||
scope.begin_run()
|
||||
try:
|
||||
return await self._run_once_with_scope(
|
||||
scope=scope, command=command, timeout=timeout, cwd=cwd, env=env,
|
||||
shell=shell, login=login, confirm_dangerous=confirm_dangerous,
|
||||
)
|
||||
finally:
|
||||
scope.finish_run()
|
||||
|
||||
@staticmethod
|
||||
async def _acquire_command_slot(scope: TerminalScope) -> None:
|
||||
"""并发槽等待期间响应作用域封口,禁止取消后迟到启动一次性进程。"""
|
||||
acquire_task = asyncio.create_task(_command_semaphore.acquire())
|
||||
closed_task = asyncio.create_task(scope.changed.wait())
|
||||
acquired = False
|
||||
released = False
|
||||
try:
|
||||
done, _ = await asyncio.wait(
|
||||
{acquire_task, closed_task}, return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if closed_task in done and acquire_task not in done:
|
||||
acquire_task.cancel()
|
||||
await asyncio.gather(acquire_task, return_exceptions=True)
|
||||
raise TerminalAccessError()
|
||||
await acquire_task
|
||||
acquired = True
|
||||
if scope.closed:
|
||||
_command_semaphore.release()
|
||||
released = True
|
||||
acquired = False
|
||||
raise TerminalAccessError()
|
||||
finally:
|
||||
if not acquire_task.done():
|
||||
acquire_task.cancel()
|
||||
await asyncio.gather(acquire_task, return_exceptions=True)
|
||||
if acquire_task.done() and not acquire_task.cancelled() and not acquired and not released:
|
||||
_command_semaphore.release()
|
||||
if not closed_task.done():
|
||||
closed_task.cancel()
|
||||
await asyncio.gather(closed_task, return_exceptions=True)
|
||||
|
||||
async def _run_once_with_scope(
|
||||
self,
|
||||
*,
|
||||
scope: TerminalScope,
|
||||
command: str,
|
||||
timeout: Optional[int],
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[dict[str, Any]] = None,
|
||||
shell: Optional[str] = None,
|
||||
login: Optional[bool] = None,
|
||||
confirm_dangerous: bool = False,
|
||||
) -> str:
|
||||
"""在已登记作用域下运行一次命令,并对封口和进程收尾保持可观察。"""
|
||||
normalized_timeout, timeout_note = self._normalize_timeout(timeout)
|
||||
normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting("ROOT_PATH"))
|
||||
normalized_env = build_agent_subprocess_env(env)
|
||||
shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd)
|
||||
|
||||
async with _command_semaphore:
|
||||
await self._acquire_command_slot(scope)
|
||||
try:
|
||||
require_terminal_scope()
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*shell_policy.build_argv(command), cwd=normalized_cwd, env=normalized_env,
|
||||
**self._subprocess_kwargs(),
|
||||
@@ -545,31 +608,41 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
]
|
||||
|
||||
timed_out = False
|
||||
scope_cancelled = False
|
||||
scope_task = asyncio.create_task(scope.changed.wait())
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(wait_task), timeout=normalized_timeout
|
||||
done, _ = await asyncio.wait(
|
||||
{wait_task, scope_task}, timeout=normalized_timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
await self._cleanup_process(process, wait_task)
|
||||
scope_cancelled = scope_task in done
|
||||
if wait_task not in done:
|
||||
timed_out = not scope_cancelled
|
||||
await self._cleanup_process(process, wait_task)
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_process(process, wait_task)
|
||||
raise
|
||||
|
||||
finally:
|
||||
if not scope_task.done():
|
||||
scope_task.cancel()
|
||||
await asyncio.gather(scope_task, return_exceptions=True)
|
||||
try:
|
||||
await self._finish_reader_tasks(reader_tasks)
|
||||
finally:
|
||||
output.close()
|
||||
|
||||
return self._format_run_result(
|
||||
exit_code=process.returncode,
|
||||
output=output,
|
||||
timeout=normalized_timeout,
|
||||
timed_out=timed_out,
|
||||
timeout_note=timeout_note,
|
||||
cwd=normalized_cwd, shell=shell_policy.executable, login=shell_policy.login,
|
||||
)
|
||||
return self._format_run_result(
|
||||
exit_code=process.returncode,
|
||||
output=output,
|
||||
timeout=normalized_timeout,
|
||||
timed_out=timed_out,
|
||||
scope_cancelled=scope_cancelled,
|
||||
timeout_note=timeout_note,
|
||||
cwd=normalized_cwd, shell=shell_policy.executable, login=shell_policy.login,
|
||||
)
|
||||
finally:
|
||||
_command_semaphore.release()
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -601,6 +674,7 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
try:
|
||||
require_terminal_scope()
|
||||
terminal_session_manager = get_terminal_session_manager()
|
||||
output_budget = DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
if self.result_max_chars and self.result_max_chars > 0:
|
||||
@@ -692,6 +766,11 @@ class ExecuteCommandTool(MoviePilotTool):
|
||||
)
|
||||
|
||||
raise ValueError(f"不支持的 action: {action}")
|
||||
except TerminalAccessError as err:
|
||||
return self._dump({
|
||||
"error": str(err), "status": "error", "action": normalized_action,
|
||||
"success": False, "execution_outcome": "failed", "code": "terminal_access_denied",
|
||||
})
|
||||
except TerminalOutputError as err:
|
||||
return self._dump({
|
||||
"error": str(err), "status": "error", "action": normalized_action,
|
||||
|
||||
@@ -6,6 +6,12 @@ import threading
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
close_terminal_scope,
|
||||
current_terminal_scope,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,6 +27,7 @@ class ToolDefinition:
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, description: str, input_schema: Dict[str, Any]):
|
||||
"""保存严格工具目录对外展示的名称、说明和参数合同。"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.input_schema = input_schema
|
||||
@@ -34,7 +41,7 @@ class MoviePilotToolsManager:
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str = "api_user",
|
||||
session_id: str = uuid.uuid4(),
|
||||
session_id: Optional[str] = None,
|
||||
is_admin: bool = True,
|
||||
policy_orchestrator: Optional[AgentToolPolicyOrchestrator] = None,
|
||||
data: Optional[AgentDataContext] = None,
|
||||
@@ -47,7 +54,8 @@ class MoviePilotToolsManager:
|
||||
session_id: 会话ID
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.session_id = session_id
|
||||
self.session_id = session_id if session_id is not None else uuid.uuid4().hex
|
||||
self._terminal_scope = TerminalScope(user_id=user_id, task_id=self.session_id, kind="operator")
|
||||
self.is_admin = is_admin
|
||||
self.policy_orchestrator = policy_orchestrator
|
||||
self._data = data
|
||||
@@ -423,7 +431,9 @@ class MoviePilotToolsManager:
|
||||
|
||||
# 调用工具的run方法。HTTP/MCP 工具调用不会经过 BaseTool._arun,
|
||||
# 因此这里也必须复用同一套返回值格式化和兜底截断逻辑。
|
||||
result = await tool_instance.run_with_timeout(**normalized_arguments)
|
||||
# 嵌套宿主调用保留任务身份;独立内部入口使用该管理器的专属作用域。
|
||||
with bind_terminal_scope(current_terminal_scope() or self._terminal_scope):
|
||||
result = await tool_instance.run_with_timeout(**normalized_arguments)
|
||||
str_result = format_tool_result_for_agent(
|
||||
result,
|
||||
tool_name=tool_name,
|
||||
@@ -463,6 +473,10 @@ class MoviePilotToolsManager:
|
||||
)
|
||||
return str_result
|
||||
|
||||
async def close(self) -> bool:
|
||||
"""封闭本内部调用方的终端作用域,真实进程未收敛时允许调用方重试。"""
|
||||
return await close_terminal_scope(self._terminal_scope)
|
||||
|
||||
@staticmethod
|
||||
def _convert_to_json_schema(args_schema: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
56
docs/agent-codex-parity-plan.md
Normal file
56
docs/agent-codex-parity-plan.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# MoviePilot Agent 与 Codex Harness 对齐路线
|
||||
|
||||
更新时间:2026-09-11
|
||||
|
||||
这份文档是 Agent 能力对齐的交付路线和每轮验收合同。它记录当前证据与未完成目标,不把一次成功的模型调用当成“已经和 Codex 一样聪明”。最终判断必须同时看模型行为、工具执行、任务生命周期、业务终态和失败后的收敛结果。
|
||||
|
||||
## 当前目标
|
||||
|
||||
让 MoviePilot Agent 在 MoviePilot 的真实业务边界内具备可复核的 Codex 级 Harness 能力:模型能看到准确的工具合同,工具能完成完整的命令行、浏览器、终端输入输出、子代理和长任务协作闭环,宿主能隔离任务身份、收口进程并在新消息到达时继续推进。高影响业务动作仍由宿主的授权和确认策略控制。
|
||||
|
||||
真实模型评测的固定档位使用 **luna-max**:模型标识为 `gpt-5.6-luna`,推理预算为 `max`。报告必须同时记录这两个字段;更换模型、推理预算、提示集或判定器后,结果属于新的基线,不能与旧报告直接合并。
|
||||
|
||||
## 交付顺序
|
||||
|
||||
| 目标 | 状态 | 验收重点 |
|
||||
| --- | --- | --- |
|
||||
| C1.4 终端工具完整闭环 | 已完成基线 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试 |
|
||||
| C1.3 终端任务作用域 | 本轮进行中 | 终端归属由宿主对象身份决定;定时运行、会话、子任务和内部工具管理器隔离;封口先于清理,排队或运行中的命令都不会在任务结束后迟到启动 |
|
||||
| S2.3 运行中消息排队与 WebAgent 输入 | 下一目标 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 能报告 queued/applied,停止后不再派发后续工具 |
|
||||
| 浏览器能力对齐 | S2.3 后续 | 导航、页面读取、点击/输入、等待、截图和失败收口使用真实浏览器状态;工具清单、权限、超时、重试和会话生命周期与命令行能力同样可观测 |
|
||||
| S3.1 通用子代理 | S2.3 后 | 主 Agent 按任务动态派发通用子代理;保留专用画像必须有独立收益证据。子代理的授权、工具角色、终端分享和副作用边界不能因画像切换而放宽 |
|
||||
|
||||
## 每轮硬门禁
|
||||
|
||||
每个影响 Agent、工具、会话、浏览器、子代理或提示词的代码轮次都要执行下面四层检查,并把结果与最终提交 SHA 绑定:
|
||||
|
||||
1. **确定性回归**:运行受影响的 pytest、全量 Agent 测试、类型/格式/架构 ratchet 和必要的全量测试。任何已知基线失败都要在未改动基线复现后再归因。
|
||||
2. **真实 MoviePilot 运行**:使用 `gpt-5.6-luna` + `max`,在隔离 worker 中跑固定场景,保留真实模型请求、工具轨迹、耗时、token、终态和进程收尾证据。业务副作用只能进入评测假世界,不能把测试凭据或私有响应写入报告。
|
||||
3. **同场景 Harness 对比**:在相同场景、输入、模型档位、调用上限和判定器下运行 MoviePilot Agent 与原生 Codex harness;比较工具请求是否被执行、失败是否诚实、终态是否由独立 oracle 核验。原生 harness 不可用时报告 `blocked`,不能以离线脚本或单元测试替代。
|
||||
4. **留存与复核**:报告目录使用 `evidence/agent-round/<commit-sha>/`(不提交凭据),至少包含 `model`, `reasoning_effort`, `scenario`, `agent_sha`, `harness_sha`, `model_calls`, `tokens`, `elapsed_seconds`, `tool_trace`, `business_oracle`, `process_oracle` 和失败分类。重复运行同一轮时保留每次报告,不能覆盖异常样本。
|
||||
|
||||
当前评测入口:
|
||||
|
||||
```bash
|
||||
UV_PROJECT_ENVIRONMENT=/Users/jxxghp/MPProjects/MoviePilot/.venv \
|
||||
uv run --locked --no-sync python -m scripts.evaluation \
|
||||
--live --scenario unknown_download \
|
||||
--model gpt-5.6-luna --reasoning-effort max \
|
||||
--output evidence/agent-round/<commit-sha>/moviepilot-unknown_download.json
|
||||
```
|
||||
|
||||
同一场景的原生 harness 使用 `--native`,报告必须和 `--live` 放在同一个提交目录下。真实评测不能在 pytest 中隐式联网;命令失败、模型拒绝、token 不完整或 oracle 未核验都标记为未完成,不得记为通过。
|
||||
|
||||
## 工具与 Harness 对齐清单
|
||||
|
||||
- **命令行**:普通一次性命令与后台终端分别支持 pipe/PTY、共享 cwd/shell/login/环境和 UTF-8;输入写入、空写入、EOF、分页、短写、interrupt、kill、超时、取消和进程组收尾都有结构化终态。
|
||||
- **浏览器**:工具目录必须明确导航、读取、交互、等待和截图的动作与权限;浏览器会话、页面状态和失败重试由宿主持有,不能由模型字符串冒领。
|
||||
- **子代理**:主 Agent 自动选择通用子代理;专用画像只有在 held-out 任务上证明提高成功率、减少调用或降低副作用风险时才保留。子代理不能自行发送消息、执行高影响写操作或继承兄弟任务句柄。
|
||||
- **长任务与新消息**:入站消息在任务运行时仍可接受并进入有界队列;应用到下一模型边界时必须保留 tool-call/tool-result 配对和取消语义,不能只把文本拼到系统提示词。
|
||||
- **观察证据**:最终答案只能引用宿主记录的工具结果和业务 oracle;“模型说完成”不能替代数据库、下载器、浏览器或进程状态核验。
|
||||
|
||||
## 通过标准与未完成边界
|
||||
|
||||
“对齐”至少需要多个固定场景、多个重复轮次和一组未见场景在同一模型档位下持续通过,并且命令行、浏览器、消息排队、子代理和取消路径都具备失败证据。一次 live pilot、单个场景或单元测试通过只能证明局部合同成立,不能证明与 Codex 的整体智能相等。
|
||||
|
||||
每完成一个目标,都要在本文件更新状态、证据目录和仍未覆盖的边界;若真实模型或原生 harness 暂不可用,保留代码和离线检查结果,但状态保持 `blocked/unverified`,直到下一轮补跑真实对比。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Agent 复杂任务执行与恢复
|
||||
|
||||
MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务。本次增强关注长任务的连续性:明确目标和检查结果,按需补充工具,并在执行中断后保留已知事实。
|
||||
MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务。本次增强关注长任务的连续性:明确目标和检查结果,按需补充工具,并在执行中断后保留已知事实。能力对齐路线与每轮真实运行验收见 [Agent 与 Codex Harness 对齐路线](agent-codex-parity-plan.md)。
|
||||
|
||||
## 任务计划
|
||||
|
||||
@@ -34,6 +34,12 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
|
||||
|
||||
管道会话可用 `write(input_text="末段输入", close_stdin=true)` 在交付末段后关闭输入,或用空输入显式关闭;输出仍通过 `read/wait` 读取,`stdin_closed` 表示输入已关闭。普通空 `write` 不发送 EOF;输入关闭后不能再追加数据,重复空关闭可确认已有状态。`run` 的 stdin 始终为 EOF。PTY 的输入和输出共用端点,因此拒绝 `close_stdin=true`,也不会先写入附带输入或关闭输出;PTY 控制字节的行为取决于终端模式,pipe 中 `\u0003`、`\u0004` 只是普通字节。
|
||||
|
||||
终端句柄绑定宿主创建的用户及任务作用域,知道 `session_id` 不代表可以操作它。对话正常多轮和图重建保留同一作用域;每次定时执行按真实 `run_id` 单独持有终端,执行结束、失败或取消后回收。清空对话、空闲回收或同会话更换用户时封闭旧作用域;只清理它拥有的进程,未收敛时保留记录供重试。停止当前交互推理仍保留已交付的后台终端,后续对话可继续读取或显式终止。
|
||||
|
||||
父任务使用 `task` 或 `subagent_task` 的 `terminal_sessions=[{"session_id":"term_…","actions":["read","wait"]}]` 显式分享指定终端给该子任务。批次及管道分别在每个任务定义里声明;提示词中提到句柄不会授权,兄弟任务也不会继承。子任务保有独立作用域,结束时撤销分享但不终止父进程;当前只读子代理策略继续限制动作。宿主控制授权与工具角色权限分别校验,共享不能扩大角色权限。
|
||||
|
||||
缺少、已关闭或无权访问的终端均返回 `terminal_access_denied`,不回显其他任务的命令和输出。进程重启后不恢复旧句柄,也不会自动重跑命令。内部 `MoviePilotToolsManager` 每个实例独立持有作用域,通过 `close()` 收口;直接调用命令工具必须由宿主绑定真实上下文,工具 JSON 不能声明或覆盖身份。
|
||||
|
||||
`interrupt` 仅发送一次 POSIX `SIGINT` 或支持的 Windows `CTRL_BREAK_EVENT`,不等待升级强杀,也不把会话标记为主动终止;实际命令可以处理信号后继续运行,也可能自行退出。回包含实际 `signal` 和 `signal_sent`。不具备该控制能力时明确失败;`kill` 仍负责终止并在需要时强杀,但未知名称、无效编号和没有真实映射的信号会在任何状态修改前拒绝。两种输入控制均不增加只读子代理权限。
|
||||
|
||||
后台命令 `start` 默认最多等待 250ms 的首次输出,可用 `yield_time_ms=0` 立即返回。后续 `read/wait/write/interrupt/kill` 用返回的 `output_until_seq` 和 `output_until_offset` 一起续读:前者是完整交付的最后一个分片,后者是下一分片中已交付的 UTF-8 字节位置。首次传 `since_offset=0` 开启分片内分页;不传 offset 时保持完整分片模式,小页装不下一个分片会明确提示调整限额。`last_seq` 只表示已经产生的输出,不能用于跳过未读内容。
|
||||
|
||||
@@ -749,15 +749,15 @@ flowchart LR
|
||||
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
|
||||
- 延迟导入不被接受为隐藏循环依赖的手段。
|
||||
|
||||
### 10.1 2026-09-03 当前收口状态与后续边界
|
||||
### 10.1 2026-09-11 当前收口状态与后续边界
|
||||
|
||||
当前宿主架构基线(排除 `app/plugins/**`)如下;数字来自
|
||||
`tests/fixtures/architecture/`,更新基线前必须先审查语义变化:
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1001 |
|
||||
| 内部导入边 | 8,493 |
|
||||
| Python 模块 | 1004 |
|
||||
| 内部导入边 | 8,513 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1001 / 8,493 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与 AMLL 歌词模块的受控依赖 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1004 / 8,513 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,397 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| 全量 mypy 历史债务 | 9,396 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 527 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -552,6 +552,11 @@ PTY 会在写入之前拒绝 half-close,空 `write` 不代表 EOF,控制字
|
||||
新增 `interrupt` 只发送一次平台支持的中断并返回 `signal/signal_sent`,不会升级强杀;
|
||||
`kill` 保留终止语义但提前拒绝无效信号。上述能力仍为内置管理员 Agent 工具,不扩大外部 MCP 目录。
|
||||
|
||||
后台句柄按宿主用户和真实任务作用域隔离;相同对话的不同定时 `run_id` 也不会共享终端。
|
||||
正常对话可跨轮继续,清空/更换用户或定时运行收尾只回收自身进程。子任务仅在父任务明确提供
|
||||
`terminal_sessions` 时获得指定 `read/wait` 授权,不能凭句柄或任务文字访问其他终端。
|
||||
缺失、封闭或无权限均返回 `terminal_access_denied`,不会回显其他任务状态;宿主重启不恢复旧句柄。
|
||||
|
||||
后台命令的 `start` 新增 `yield_time_ms`(默认 250、上限 10000,0 不等待)。
|
||||
`read/wait/write/interrupt/kill` 接受 `since_seq` 和 `since_offset`:一起传回上次响应的
|
||||
`output_until_seq/output_until_offset`,后者表示下一分片内的 UTF-8 字节位置;
|
||||
|
||||
@@ -95,7 +95,8 @@ to make the directory tree look symmetrical.
|
||||
| `app/agent/tools/result.py` | Pure interpretation of explicit tool outcomes and portable tool-image history; image observation copies never replace original user attachments |
|
||||
| `app/agent/api/arguments.py` | Canonical API request fingerprints from the generated operation schema and the executor's GET projection; no endpoint imports or live discovery |
|
||||
| `app/agent/shell.py` | Shared command interpreter, login mode, launch directory and subprocess text-encoding policy for run, pipe and PTY; preserves Windows default priority and UTF-8 behavior |
|
||||
| `app/agent/terminal/` | `session.py` owns terminal state, input serialization, retained output and UTF-8 stream decoding; `manager.py` owns process launch, read/write/EOF, signals, paging and bounded shutdown. Package root contains no implementation exports |
|
||||
| `app/agent/terminal/` | `ownership.py` owns host task identity, invocation context and lazy scope closure; `session.py` owns process state, input serialization and UTF-8 capture; `output.py` owns pure paging and bounded projections; `manager.py` owns launch, access grants and process lifecycle. Package root contains no implementation exports |
|
||||
| `app/agent/middleware/terminal.py` | Explicit child terminal grants and per-invocation scope binding; cached child graphs never receive mutable task identities, and sharing never bypasses tool role permissions |
|
||||
| `app/agent/policy/api.py` | Fixed `moviepilot_api` operation registry, HTTP route templates and per-operation authorization/effect policy; no arbitrary URL or method input |
|
||||
| `app/agent/policy/mcp.py` | Generated external MCP input-contract builder for the fixed API registry; owns exact English oneOf parameter projection, not runtime authorization |
|
||||
| `app/agent/tools/impl/service.py` | Admin-only external MCP wrappers for downloader, media-server and database Skill scripts; synchronous scripts run only through the Agent blocking executor |
|
||||
@@ -1091,7 +1092,7 @@ driven workflow registration.
|
||||
| `app/agent/tasks.py` | Background prompt, scheduled task and heartbeat execution owner |
|
||||
| `app/agent/orchestrator.py` | Per-session `MoviePilotAgent` execution and LLM/tool/middleware orchestration only |
|
||||
| `app/agent/shell.py` | Agent command interpreter/login/directory and subprocess UTF-8 policy shared by run, pipe and PTY |
|
||||
| `app/agent/terminal/` | Terminal session state and process/input/output lifecycle; lazy shutdown resolves the materialized manager module without importing it |
|
||||
| `app/agent/terminal/` | Terminal ownership, explicit sharing and independent task cleanup; pure output paging stays outside the process manager, while lazy scope and global shutdown resolve only an already materialized manager |
|
||||
| `app/agent/loader.py` | Agent-specific capability discovery and canonical entrypoint/service materialization; reuses the generic Capability Runtime while keeping Agent ownership under `app/agent/` |
|
||||
| `app/agent/__init__.py` | Implementation-free package root; exact historical Agent symbols are supplied by the Compat overlay only, while host callers import `orchestrator.py` or the relevant owner directly |
|
||||
| `app/agent/llm/__init__.py` | Implementation-free package root; only the verified historical `LLMHelper` symbol is supplied by exact Compat routing |
|
||||
|
||||
@@ -706,3 +706,13 @@ def pytest_sessionfinish(session, exitstatus):
|
||||
raise RuntimeError("log writer did not converge")
|
||||
except Exception as err:
|
||||
_report_session_cleanup_error(session, "logger manager", err)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def terminal_scope():
|
||||
"""仅由终端合同测试显式选用的宿主身份,结束时还原上下文。"""
|
||||
from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope
|
||||
|
||||
scope = TerminalScope(user_id="terminal-test-owner", task_id="terminal-test", kind="test")
|
||||
with bind_terminal_scope(scope):
|
||||
yield scope
|
||||
|
||||
@@ -164,7 +164,8 @@
|
||||
"owners": {
|
||||
"_TerminalSessionManager._start_pipe_session": 3,
|
||||
"_TerminalSessionManager._start_pty_session": 2,
|
||||
"_TerminalSessionManager.start": 1
|
||||
"_TerminalSessionManager._wait_for_change": 1,
|
||||
"_TerminalSessionManager.start": 2
|
||||
},
|
||||
"target": "asyncio.create_task"
|
||||
},
|
||||
@@ -182,7 +183,8 @@
|
||||
},
|
||||
"app/agent/tools/impl/execute_command.py:asyncio.create_task": {
|
||||
"owners": {
|
||||
"ExecuteCommandTool._run_once": 3
|
||||
"ExecuteCommandTool._acquire_command_slot": 2,
|
||||
"ExecuteCommandTool._run_once_with_scope": 4
|
||||
},
|
||||
"target": "asyncio.create_task"
|
||||
},
|
||||
|
||||
@@ -1074,8 +1074,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8493,
|
||||
"edge_sha256": "cf90c1e6dbf33bbb0f9c6fee9bc62f7cf5be26f70658da0cab394627913ee69d",
|
||||
"edge_count": 8513,
|
||||
"edge_sha256": "7871419d809ba1a220809e382a64428c4c7a28e7ead4107c62185cb2a00ae794",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1534,6 +1534,7 @@
|
||||
"app.agent.middleware.subagents -> app.agent.middleware",
|
||||
"app.agent.middleware.subagents -> app.agent.middleware.policy",
|
||||
"app.agent.middleware.subagents -> app.agent.middleware.summarization",
|
||||
"app.agent.middleware.subagents -> app.agent.middleware.terminal",
|
||||
"app.agent.middleware.subagents -> app.agent.middleware.utils",
|
||||
"app.agent.middleware.subagents -> app.agent.middleware.vision",
|
||||
"app.agent.middleware.subagents -> app.agent.policy",
|
||||
@@ -1550,6 +1551,10 @@
|
||||
"app.agent.middleware.summarization -> app.agent.middleware.usage",
|
||||
"app.agent.middleware.summarization -> app.runtime",
|
||||
"app.agent.middleware.summarization -> app.runtime.log",
|
||||
"app.agent.middleware.terminal -> app.agent",
|
||||
"app.agent.middleware.terminal -> app.agent.terminal",
|
||||
"app.agent.middleware.terminal -> app.agent.terminal.manager",
|
||||
"app.agent.middleware.terminal -> app.agent.terminal.ownership",
|
||||
"app.agent.middleware.usage -> app.runtime",
|
||||
"app.agent.middleware.usage -> app.runtime.log",
|
||||
"app.agent.middleware.vision -> app.agent",
|
||||
@@ -1590,6 +1595,8 @@
|
||||
"app.agent.orchestrator -> app.agent.policy.sanitizer",
|
||||
"app.agent.orchestrator -> app.agent.prompt",
|
||||
"app.agent.orchestrator -> app.agent.runtime",
|
||||
"app.agent.orchestrator -> app.agent.terminal",
|
||||
"app.agent.orchestrator -> app.agent.terminal.ownership",
|
||||
"app.agent.orchestrator -> app.agent.tools",
|
||||
"app.agent.orchestrator -> app.agent.tools.catalog",
|
||||
"app.agent.orchestrator -> app.agent.tools.impl",
|
||||
@@ -1656,6 +1663,8 @@
|
||||
"app.agent.session -> app.agent.contracts",
|
||||
"app.agent.session -> app.agent.memory",
|
||||
"app.agent.session -> app.agent.orchestrator",
|
||||
"app.agent.session -> app.agent.terminal",
|
||||
"app.agent.session -> app.agent.terminal.ownership",
|
||||
"app.agent.session -> app.application",
|
||||
"app.agent.session -> app.application.agent",
|
||||
"app.agent.session -> app.chain",
|
||||
@@ -1706,6 +1715,8 @@
|
||||
"app.agent.terminal.manager -> app.agent",
|
||||
"app.agent.terminal.manager -> app.agent.shell",
|
||||
"app.agent.terminal.manager -> app.agent.terminal",
|
||||
"app.agent.terminal.manager -> app.agent.terminal.output",
|
||||
"app.agent.terminal.manager -> app.agent.terminal.ownership",
|
||||
"app.agent.terminal.manager -> app.agent.terminal.session",
|
||||
"app.agent.terminal.manager -> app.agent.tools",
|
||||
"app.agent.terminal.manager -> app.agent.tools.impl",
|
||||
@@ -1713,8 +1724,13 @@
|
||||
"app.agent.terminal.manager -> app.runtime",
|
||||
"app.agent.terminal.manager -> app.runtime.log",
|
||||
"app.agent.terminal.manager -> app.runtime.settings",
|
||||
"app.agent.terminal.output -> app.agent",
|
||||
"app.agent.terminal.output -> app.agent.terminal",
|
||||
"app.agent.terminal.output -> app.agent.terminal.session",
|
||||
"app.agent.terminal.session -> app.agent",
|
||||
"app.agent.terminal.session -> app.agent.shell",
|
||||
"app.agent.terminal.session -> app.agent.terminal",
|
||||
"app.agent.terminal.session -> app.agent.terminal.ownership",
|
||||
"app.agent.tools.base -> app.agent",
|
||||
"app.agent.tools.base -> app.agent.callback",
|
||||
"app.agent.tools.base -> app.agent.policy",
|
||||
@@ -1843,6 +1859,8 @@
|
||||
"app.agent.tools.impl.execute_command -> app.agent.shell",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.terminal",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.terminal.manager",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.terminal.output",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.terminal.ownership",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.tools",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.tools.base",
|
||||
"app.agent.tools.impl.execute_command -> app.agent.tools.impl",
|
||||
@@ -1946,6 +1964,8 @@
|
||||
"app.agent.tools.manager -> app.agent.policy.contracts",
|
||||
"app.agent.tools.manager -> app.agent.policy.orchestrator",
|
||||
"app.agent.tools.manager -> app.agent.policy.sanitizer",
|
||||
"app.agent.tools.manager -> app.agent.terminal",
|
||||
"app.agent.tools.manager -> app.agent.terminal.ownership",
|
||||
"app.agent.tools.manager -> app.agent.tools",
|
||||
"app.agent.tools.manager -> app.agent.tools.base",
|
||||
"app.agent.tools.manager -> app.agent.tools.catalog",
|
||||
@@ -9571,7 +9591,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1001,
|
||||
"module_count": 1004,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9661,6 +9681,7 @@
|
||||
"app.agent.middleware.skills",
|
||||
"app.agent.middleware.subagents",
|
||||
"app.agent.middleware.summarization",
|
||||
"app.agent.middleware.terminal",
|
||||
"app.agent.middleware.usage",
|
||||
"app.agent.middleware.utils",
|
||||
"app.agent.middleware.vision",
|
||||
@@ -9683,6 +9704,8 @@
|
||||
"app.agent.tasks",
|
||||
"app.agent.terminal",
|
||||
"app.agent.terminal.manager",
|
||||
"app.agent.terminal.output",
|
||||
"app.agent.terminal.ownership",
|
||||
"app.agent.terminal.session",
|
||||
"app.agent.tools",
|
||||
"app.agent.tools.base",
|
||||
|
||||
@@ -342,7 +342,6 @@
|
||||
"union-attr": 1
|
||||
},
|
||||
"app/agent/tools/manager.py": {
|
||||
"assignment": 1,
|
||||
"attr-defined": 2,
|
||||
"type-arg": 1
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.tools.impl import execute_command as command_module
|
||||
from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOutput
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
def _python(code: str) -> str:
|
||||
"""使用当前解释器和逐参数转义,真实程序仅接触临时目录及标准流。"""
|
||||
@@ -219,7 +221,7 @@ async def test_lazy_shutdown_closes_the_materialized_terminal_package_owner(comm
|
||||
action="start", command=_python("print('READY', flush=True); input()"), use_pty=False,
|
||||
env={"SHELL": "/bin/sh"}, since_offset=0,
|
||||
))
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
assert session.process is not None and session.process.returncode is None
|
||||
await close_materialized_terminal_sessions()
|
||||
assert manager._closed is True and manager._sessions == {}
|
||||
|
||||
@@ -29,6 +29,8 @@ from app.agent.tools.impl.execute_command import ExecuteCommandTool, _CommandOut
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
from app.agent.tools.result import EXECUTION_OUTCOME_KEY, inspect_tool_result
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
def _command(code: str) -> str:
|
||||
"""固定使用当前虚拟环境解释器,并对每个 shell 参数独立转义。"""
|
||||
|
||||
163
tests/test_agent_command_scope.py
Normal file
163
tests/test_agent_command_scope.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""真实命令入口必须使用宿主作用域,工具参数和内部调用者不能冒领其他终端。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent.terminal import manager as terminal_module
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope, close_terminal_scope
|
||||
from app.agent.tools.impl import execute_command as command_module
|
||||
from app.agent.tools.impl.execute_command import ExecuteCommandTool
|
||||
from app.agent.tools.manager import MoviePilotToolsManager
|
||||
|
||||
|
||||
def _caller() -> MoviePilotToolsManager:
|
||||
"""仅替换目录/回执边界,使用真实内部调用入口、工具和终端管理器。"""
|
||||
caller = MoviePilotToolsManager(user_id="owner")
|
||||
tool = ExecuteCommandTool(session_id=caller.session_id, user_id="owner")
|
||||
caller.get_strict_tool = lambda _name: tool
|
||||
caller._ensure_policy_runtime = lambda: (
|
||||
SimpleNamespace(start=lambda **_kwargs: None), SimpleNamespace(agent_context={}),
|
||||
)
|
||||
return caller
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unbound_tool_cannot_launch_or_claim_an_owner(monkeypatch):
|
||||
"""模型自行填写身份字段不能替代宿主,拒绝发生在获取进程管理器之前。"""
|
||||
create = AsyncMock()
|
||||
monkeypatch.setattr(command_module, "get_terminal_session_manager", create)
|
||||
tool = ExecuteCommandTool(session_id="conversation", user_id="owner")
|
||||
result = json.loads(await tool.run(action="start", command="echo not-started", owner="owner", task_id="task"))
|
||||
assert result["code"] == "terminal_access_denied"
|
||||
assert result["execution_outcome"] == "failed"
|
||||
create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_callers_own_distinct_terminals_and_close_independently(monkeypatch):
|
||||
"""同用户的两个内部调用者不能靠真实 handle 串读,关闭 A 不终止 B。"""
|
||||
manager = _TerminalSessionManager()
|
||||
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
|
||||
monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager)
|
||||
first, second = _caller(), _caller()
|
||||
assert first.session_id != second.session_id
|
||||
command = shlex.join([sys.executable, "-u", "-c", "print('PRIVATE-OUTPUT', flush=True); input()"])
|
||||
try:
|
||||
a = json.loads(await first.call_tool("execute_command", {"action": "start", "command": command, "use_pty": False}))
|
||||
b = json.loads(await second.call_tool("execute_command", {"action": "start", "command": command, "use_pty": False}))
|
||||
denied = json.loads(await second.call_tool("execute_command", {"action": "read", "session_id": a["session_id"]}))
|
||||
assert denied["code"] == "terminal_access_denied"
|
||||
assert "PRIVATE-OUTPUT" not in json.dumps(denied)
|
||||
assert await asyncio.wait_for(first.close(), 5)
|
||||
own = json.loads(await second.call_tool("execute_command", {"action": "read", "session_id": b["session_id"]}))
|
||||
assert own["status"] == "running"
|
||||
assert manager._sessions[b["session_id"]].process.returncode is None
|
||||
assert await asyncio.wait_for(second.close(), 5)
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_internal_call_keeps_current_host_identity(monkeypatch):
|
||||
"""图内转调工具管理器时继承当前任务,不能静默改成内部调用者的独立身份。"""
|
||||
manager = _TerminalSessionManager()
|
||||
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
|
||||
monkeypatch.setattr(command_module, "get_terminal_session_manager", lambda: manager)
|
||||
caller = _caller()
|
||||
host = TerminalScope(user_id="owner", task_id="scheduled-run-1", kind="scheduled")
|
||||
try:
|
||||
with bind_terminal_scope(host):
|
||||
result = json.loads(await caller.call_tool("execute_command", {"action": "start", "command": "echo nested", "use_pty": False}))
|
||||
assert manager._sessions[result["session_id"]].owner is host
|
||||
denied = json.loads(await caller.call_tool("execute_command", {"action": "read", "session_id": result["session_id"]}))
|
||||
assert denied["code"] == "terminal_access_denied"
|
||||
finally:
|
||||
await manager.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scope_sealed_while_waiting_for_command_slot_never_starts_process(monkeypatch):
|
||||
"""一次性命令排队期间任务被关闭时,不能迟到创建子进程。"""
|
||||
scope = TerminalScope(user_id="owner", task_id="queued-run", kind="scheduled")
|
||||
slot = asyncio.Semaphore(0)
|
||||
created = AsyncMock()
|
||||
monkeypatch.setattr(command_module, "_command_semaphore", slot)
|
||||
monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", created)
|
||||
tool = ExecuteCommandTool(session_id="queued-run", user_id="owner")
|
||||
|
||||
with bind_terminal_scope(scope):
|
||||
pending = asyncio.create_task(tool.run(action="run", command="echo never"))
|
||||
for _ in range(20):
|
||||
if scope._active_runs:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
scope.seal()
|
||||
result = json.loads(await asyncio.wait_for(pending, 2))
|
||||
assert result["code"] == "terminal_access_denied"
|
||||
created.assert_not_awaited()
|
||||
assert await scope.wait_runs()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_cancelled_while_waiting_for_command_slot_releases_waiter(monkeypatch):
|
||||
"""调用方取消并发槽等待时,不得留下迟到获取槽位的后台任务。"""
|
||||
scope = TerminalScope(user_id="owner", task_id="cancelled-run", kind="scheduled")
|
||||
slot = asyncio.Semaphore(0)
|
||||
created = AsyncMock()
|
||||
monkeypatch.setattr(command_module, "_command_semaphore", slot)
|
||||
monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", created)
|
||||
tool = ExecuteCommandTool(session_id="cancelled-run", user_id="owner")
|
||||
|
||||
with bind_terminal_scope(scope):
|
||||
pending = asyncio.create_task(tool.run(action="run", command="echo never"))
|
||||
for _ in range(20):
|
||||
if scope._active_runs:
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
pending.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await pending
|
||||
assert await scope.wait_runs()
|
||||
slot.release()
|
||||
await asyncio.sleep(0)
|
||||
created.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scope_close_waits_for_real_process_and_returns_failed(monkeypatch):
|
||||
"""运行中的一次性命令响应封口并真实收尾,作用域不能提前报告成功。"""
|
||||
manager = _TerminalSessionManager()
|
||||
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
|
||||
started = asyncio.Event()
|
||||
original_create = command_module.asyncio.create_subprocess_exec
|
||||
|
||||
async def create(*args, **kwargs):
|
||||
"""观察真实 subprocess 创建边界,不替换子进程本身。"""
|
||||
process = await original_create(*args, **kwargs)
|
||||
started.set()
|
||||
return process
|
||||
|
||||
monkeypatch.setattr(command_module.asyncio, "create_subprocess_exec", create)
|
||||
scope = TerminalScope(user_id="owner", task_id="running-run", kind="scheduled")
|
||||
tool = ExecuteCommandTool(session_id="running-run", user_id="owner")
|
||||
command = shlex.join([sys.executable, "-u", "-c", "import time; print('READY', flush=True); time.sleep(30)"])
|
||||
try:
|
||||
with bind_terminal_scope(scope):
|
||||
running = asyncio.create_task(tool.run(action="run", command=command, timeout=60))
|
||||
await asyncio.wait_for(started.wait(), 5)
|
||||
closing = asyncio.create_task(close_terminal_scope(scope))
|
||||
result = json.loads(await asyncio.wait_for(running, 10))
|
||||
assert result["success"] is False
|
||||
assert result["status"] == "cancelled"
|
||||
assert result["execution_outcome"] == "failed"
|
||||
assert await asyncio.wait_for(closing, 2)
|
||||
assert scope._active_runs == 0
|
||||
finally:
|
||||
await manager.close()
|
||||
@@ -1029,6 +1029,7 @@ async def test_agent_manager_executes_task_with_broadcast_delivery(
|
||||
assert kwargs["reply_mode"] == ReplyMode.DISPATCH
|
||||
assert kwargs["allow_message_tools"] is True
|
||||
assert kwargs["wait_for_completion"] is True
|
||||
assert kwargs["scheduled_run_id"] == AgentTaskOper().get(task.id).last_run_id
|
||||
assert "搜索示例电影是否已有资源" in kwargs["message"]
|
||||
post_message.assert_not_awaited()
|
||||
|
||||
@@ -1215,8 +1216,8 @@ async def test_cached_agent_clears_channel_for_background_task() -> None:
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cached_agent_overwrites_channel_admin_with_explicit_false() -> None:
|
||||
"""复用会话 Agent 时,明确非管理员结论必须覆盖上一轮管理员身份。"""
|
||||
async def test_cached_agent_overwrites_channel_admin_with_explicit_false(monkeypatch) -> None:
|
||||
"""同会话换用户必须清理旧实例,以新实例装配明确的非管理员身份。"""
|
||||
manager = AgentManager()
|
||||
agent = MoviePilotAgent(
|
||||
session_id="channel-admin-cached-session",
|
||||
@@ -1238,12 +1239,17 @@ async def test_cached_agent_overwrites_channel_admin_with_explicit_false() -> No
|
||||
is_channel_admin=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", AsyncMock(return_value="完成"))
|
||||
result = await manager._process_message_internal(task)
|
||||
|
||||
replacement = manager.active_agents[agent.session_id]
|
||||
assert result == "完成"
|
||||
assert agent.user_id == "user-2"
|
||||
assert agent.username == "admin"
|
||||
assert agent.is_channel_admin is False
|
||||
assert replacement is not agent
|
||||
assert agent.user_id == "user-1"
|
||||
assert agent._terminal_scope.closed is True
|
||||
assert replacement.user_id == "user-2"
|
||||
assert replacement.username == "admin"
|
||||
assert replacement.is_channel_admin is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -27,6 +27,8 @@ from app.agent.tools.impl.execute_command import ExecuteCommandTool
|
||||
from app.agent.tools.impl.service import _run_service_script
|
||||
from app.schemas.agent import AgentMcpServerConfig
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
def _finder(paths: dict[str, str]):
|
||||
"""构造只返回测试声明命令路径的 which 替身。"""
|
||||
|
||||
@@ -22,10 +22,13 @@ from app.agent.policy.contracts import (
|
||||
)
|
||||
from app.agent.policy.orchestrator import DEFAULT_TOOL_POLICY_ORCHESTRATOR
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import current_terminal_scope
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.catalog import ToolCatalogSnapshot
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
class _SlowWriteTool(MoviePilotTool):
|
||||
"""模拟超时后外部写操作仍可能继续的工具。"""
|
||||
@@ -103,7 +106,7 @@ async def test_terminal_manager_close_terminates_running_pipe_session() -> None:
|
||||
command=_shell_command("import time; time.sleep(30)"),
|
||||
use_pty=False,
|
||||
)
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
|
||||
await manager.close()
|
||||
|
||||
@@ -120,6 +123,7 @@ async def test_terminal_manager_close_waits_for_starting_session() -> None:
|
||||
start_entered = asyncio.Event()
|
||||
allow_start = asyncio.Event()
|
||||
session = _TerminalSession(
|
||||
owner=current_terminal_scope(),
|
||||
session_id="term-starting",
|
||||
command="sleep",
|
||||
cwd=".",
|
||||
@@ -134,7 +138,7 @@ async def test_terminal_manager_close_waits_for_starting_session() -> None:
|
||||
return session
|
||||
|
||||
manager._start_pipe_session = _start_session
|
||||
manager._terminate_session = AsyncMock()
|
||||
manager._terminate_session = AsyncMock(return_value=True)
|
||||
|
||||
start_task = asyncio.create_task(manager.start(command="sleep", use_pty=False))
|
||||
await start_entered.wait()
|
||||
@@ -172,6 +176,7 @@ async def test_terminal_manager_cancellation_terminates_unregistered_session() -
|
||||
release_registration = asyncio.Event()
|
||||
termination_started = asyncio.Event()
|
||||
session = _TerminalSession(
|
||||
owner=current_terminal_scope(),
|
||||
session_id="term-cancelled",
|
||||
command="sleep",
|
||||
cwd=".",
|
||||
@@ -192,9 +197,10 @@ async def test_terminal_manager_cancellation_terminates_unregistered_session() -
|
||||
await registration_locked.wait()
|
||||
return session
|
||||
|
||||
async def _terminate_session(_session: _TerminalSession) -> None:
|
||||
async def _terminate_session(_session: _TerminalSession) -> bool:
|
||||
"""标记取消收尾已开始,避免用固定等待猜测执行顺序。"""
|
||||
termination_started.set()
|
||||
return True
|
||||
|
||||
lock_holder = asyncio.create_task(_hold_registration_lock())
|
||||
manager._start_pipe_session = _start_session
|
||||
|
||||
396
tests/test_agent_subagent_terminal_scope.py
Normal file
396
tests/test_agent_subagent_terminal_scope.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""子任务终端授权必须按真实调用隔离,缓存图和已知句柄不产生隐式权限。"""
|
||||
|
||||
import asyncio
|
||||
import builtins
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from langchain_core.language_models.fake_chat_models import FakeListChatModel, FakeMessagesListChatModel
|
||||
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.agent.middleware import subagents as subagent_module
|
||||
from app.agent.middleware.subagents import (
|
||||
MoviePilotSubAgentMiddleware,
|
||||
SubAgentTaskControlMiddleware,
|
||||
_builtin_subagent_profiles,
|
||||
_SubAgentAgentProvider,
|
||||
)
|
||||
from app.agent.middleware.terminal import SubAgentTerminalGrant, subagent_terminal_scope
|
||||
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.terminal import manager as terminal_module
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalAccessError,
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
current_terminal_scope,
|
||||
require_terminal_scope,
|
||||
)
|
||||
from app.agent.tools.impl.execute_command import ExecuteCommandTool
|
||||
|
||||
|
||||
class _TerminalReadModel(FakeMessagesListChatModel):
|
||||
"""只固定工具协议,终端动作经过真实子图 ToolNode、策略和管理器。"""
|
||||
|
||||
terminal_handle: str
|
||||
|
||||
def bind_tools(self, _tools: Any, **_kwargs: Any) -> "_TerminalReadModel":
|
||||
"""使用提供器装配的真实工具列表,无供应商或网络调用。"""
|
||||
return self
|
||||
|
||||
def _generate(self, messages: list[BaseMessage], stop: Any = None, run_manager: Any = None, **_kwargs: Any) -> ChatResult:
|
||||
"""按本次图状态生成确定性工具调用,不用共享响应序号串扰并发子图。"""
|
||||
previous = next((item for item in reversed(messages) if isinstance(item, ToolMessage)), None)
|
||||
message = AIMessage(content=str(previous.content)) if previous else AIMessage(
|
||||
content="", tool_calls=[{"id": "read-terminal", "name": "execute_command", "args": {
|
||||
"action": "read", "session_id": self.terminal_handle,
|
||||
}}],
|
||||
)
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
|
||||
def _policy() -> ToolPolicyContext:
|
||||
"""使用固定宿主用户,测试输入不能覆盖该身份。"""
|
||||
return ToolPolicyContext(
|
||||
session_id="conversation", user_id="owner", origin=ToolOrigin.SUBAGENT,
|
||||
principal_type=PrincipalType.SUBAGENT, auth_source=AuthSource.INTERNAL,
|
||||
agent_context={"is_admin": True},
|
||||
)
|
||||
|
||||
|
||||
def _provider() -> _SubAgentAgentProvider:
|
||||
"""保留真实缓存提供器,以离线图边界观察调用上下文。"""
|
||||
return _SubAgentAgentProvider(
|
||||
model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
|
||||
tools=[], policy_context=_policy(),
|
||||
)
|
||||
|
||||
|
||||
def _command() -> str:
|
||||
"""测试进程用 stdout 发布证据,并等待父任务输入后才退出。"""
|
||||
code = "import sys\nprint('PARENT_ONLY',flush=True)\ndata=sys.stdin.buffer.read()\nprint('BYTES:'+str(len(data)),flush=True)"
|
||||
args = [sys.executable, "-u", "-c", code]
|
||||
return subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def terminal_parent(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[tuple[_TerminalSessionManager, TerminalScope, str]]:
|
||||
"""测试独占管理器和父进程,失败时也回收自身创建的子进程。"""
|
||||
manager = _TerminalSessionManager()
|
||||
monkeypatch.setattr(terminal_module, "terminal_session_manager", manager)
|
||||
parent = TerminalScope(user_id="owner", task_id="parent-task", kind="interactive")
|
||||
options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {}
|
||||
try:
|
||||
with bind_terminal_scope(parent):
|
||||
payload = await asyncio.wait_for(manager.start(
|
||||
command=_command(), use_pty=False, yield_time_ms=10000, **options,
|
||||
), 5)
|
||||
assert "PARENT_ONLY" in payload["output"]
|
||||
yield manager, parent, payload["session_id"]
|
||||
finally:
|
||||
await asyncio.wait_for(manager.close(), 10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_profile_keeps_parallel_child_grants_separate(
|
||||
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""同一缓存图并发执行,只有显式获授权的真实子任务能读父终端。"""
|
||||
manager, parent, session_id = terminal_parent
|
||||
arrived = asyncio.Event()
|
||||
observations: dict[str, tuple[TerminalScope, str]] = {}
|
||||
calls = 0
|
||||
|
||||
async def invoke(state: dict[str, Any], **_kwargs: Any) -> dict[str, list[AIMessage]]:
|
||||
"""在两个子调用重叠期间读取真实管理器,不用假权限替代隔离检查。"""
|
||||
scope = require_terminal_scope()
|
||||
assert scope is not parent and scope.user_id == parent.user_id
|
||||
assert scope.task_id in {"shared-child", "sibling-child"}
|
||||
if scope.task_id == "shared-child":
|
||||
assert session_id in state["messages"][0].content
|
||||
result = (await manager.read(session_id=session_id))["output"]
|
||||
else:
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await manager.read(session_id=session_id)
|
||||
result = "denied"
|
||||
observations[scope.task_id] = (scope, result)
|
||||
if len(observations) == 2:
|
||||
arrived.set()
|
||||
await asyncio.wait_for(arrived.wait(), 5)
|
||||
assert require_terminal_scope() is scope
|
||||
return {"messages": [AIMessage(content=result)]}
|
||||
|
||||
graph = SimpleNamespace(ainvoke=invoke)
|
||||
|
||||
def create_graph(**_kwargs: Any) -> Any:
|
||||
"""记录缓存图创建次数,防止通过每次新建图掩盖共享工具问题。"""
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return graph
|
||||
|
||||
monkeypatch.setattr(subagent_module, "create_agent", create_graph)
|
||||
provider = _provider()
|
||||
with bind_terminal_scope(parent):
|
||||
results = await asyncio.gather(
|
||||
provider.run_task(description="读取父证据", subagent_type="general-purpose", task_id="shared-child",
|
||||
terminal_sessions=[SubAgentTerminalGrant(session_id=session_id)]),
|
||||
provider.run_task(description=f"尝试读取已知句柄 {session_id}", subagent_type="general-purpose", task_id="sibling-child"),
|
||||
)
|
||||
assert current_terminal_scope() is parent
|
||||
assert (await manager.read(session_id=session_id))["status"] == "running"
|
||||
assert calls == 1 and provider.get_agent("general-purpose")[1] is graph
|
||||
assert "PARENT_ONLY" in results[0] and results[1] == "denied"
|
||||
assert observations["shared-child"][0] is not observations["sibling-child"][0]
|
||||
for scope, _result in observations.values():
|
||||
assert scope.closed
|
||||
with bind_terminal_scope(scope), pytest.raises(TerminalAccessError):
|
||||
await manager.read(session_id=session_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_child_revokes_grants_and_preserves_parent_input(
|
||||
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""取消获授权的子任务只关闭子作用域,父进程仍能收到唯一完整输入。"""
|
||||
manager, parent, session_id = terminal_parent
|
||||
entered = asyncio.Event()
|
||||
child_scopes: list[TerminalScope] = []
|
||||
|
||||
async def invoke(_state: Any, **_kwargs: Any) -> Any:
|
||||
"""真实读取授权生效后保持调用运行,让外部取消触发 finally 清理。"""
|
||||
child_scopes.append(require_terminal_scope())
|
||||
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
|
||||
task = None
|
||||
try:
|
||||
with bind_terminal_scope(parent):
|
||||
task = asyncio.create_task(_provider().run_task(
|
||||
description="只读等待", subagent_type="general-purpose", task_id="cancel-child",
|
||||
terminal_sessions=[SubAgentTerminalGrant(session_id=session_id)],
|
||||
))
|
||||
await asyncio.wait_for(entered.wait(), 5)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert current_terminal_scope() is parent and not parent.closed
|
||||
payload = await manager.write(session_id=session_id, input_text="parent", close_stdin=True)
|
||||
output = payload["output"]
|
||||
async with asyncio.timeout(5):
|
||||
while not payload["output_complete"]:
|
||||
payload = await manager.wait(session_id=session_id, timeout_ms=1000,
|
||||
since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"])
|
||||
output += payload["output"]
|
||||
assert "BYTES:6" in output and payload["exit_code"] == 0
|
||||
assert child_scopes[0].closed
|
||||
with bind_terminal_scope(child_scopes[0]), pytest.raises(TerminalAccessError):
|
||||
await manager.read(session_id=session_id)
|
||||
finally:
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("parent_kind", ["missing", "unrelated"])
|
||||
async def test_share_failure_never_invokes_child_model(
|
||||
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch, parent_kind: str,
|
||||
) -> None:
|
||||
"""无父上下文或不拥有该终端的父任务不能通过委派执行模型。"""
|
||||
_manager, _parent, session_id = terminal_parent
|
||||
invoked = False
|
||||
|
||||
async def invoke(*_args: Any, **_kwargs: Any) -> Any:
|
||||
"""本边界不可触达,调用就表示授权失败仍执行了模型。"""
|
||||
nonlocal invoked
|
||||
invoked = True
|
||||
return {"messages": [AIMessage(content="unexpected")]}
|
||||
|
||||
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
|
||||
provider = _provider()
|
||||
arguments = {"description": "读取", "subagent_type": "general-purpose",
|
||||
"terminal_sessions": [SubAgentTerminalGrant(session_id=session_id)]}
|
||||
if parent_kind == "missing":
|
||||
assert current_terminal_scope() is None
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await provider.run_task(**arguments)
|
||||
else:
|
||||
unrelated = TerminalScope(user_id="owner", task_id="unrelated", kind="interactive")
|
||||
with bind_terminal_scope(unrelated), pytest.raises(TerminalAccessError):
|
||||
await provider.run_task(**arguments)
|
||||
assert not invoked
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonterminal_children_use_local_identity_without_loading_manager(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""无终端委派仍有独立子身份,但进入和退出都不物化终端单例。"""
|
||||
module_name = "app.agent.terminal.manager"
|
||||
monkeypatch.delitem(sys.modules, module_name)
|
||||
original_import = builtins.__import__
|
||||
scopes: list[TerminalScope] = []
|
||||
|
||||
def guarded_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
"""未启用终端的测试禁止隐式导入终端管理器。"""
|
||||
assert name != module_name
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
async def invoke(_state: Any, **_kwargs: Any) -> dict[str, list[AIMessage]]:
|
||||
"""记录每次调用真实 scope,确保缓存图不缓存任务身份。"""
|
||||
scopes.append(require_terminal_scope())
|
||||
return {"messages": [AIMessage(content="只读分析完成")]}
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", guarded_import)
|
||||
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
|
||||
provider = _provider()
|
||||
for task_id in ("analysis-one", "analysis-two"):
|
||||
await provider.run_task(description="分析", subagent_type="general-purpose", task_id=task_id)
|
||||
assert current_terminal_scope() is None
|
||||
assert [scope.task_id for scope in scopes] == ["analysis-one", "analysis-two"]
|
||||
assert all(scope.user_id == "owner" and scope.closed for scope in scopes)
|
||||
assert scopes[0] is not scopes[1] and module_name not in sys.modules
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_scope_restores_parent_and_rejects_redelegating_grant(
|
||||
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str],
|
||||
) -> None:
|
||||
"""嵌套普通子调用结束还原上层,获授只读句柄的子任务不能再转授。"""
|
||||
manager, parent, session_id = terminal_parent
|
||||
grants = [SubAgentTerminalGrant(session_id=session_id)]
|
||||
with bind_terminal_scope(parent):
|
||||
async with subagent_terminal_scope(task_id="child", user_id="owner", terminal_sessions=grants):
|
||||
child = require_terminal_scope()
|
||||
async with subagent_terminal_scope(task_id="nested-analysis", user_id="owner"):
|
||||
assert require_terminal_scope() is not child
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await manager.read(session_id=session_id)
|
||||
assert current_terminal_scope() is child
|
||||
with pytest.raises(TerminalAccessError):
|
||||
async with subagent_terminal_scope(task_id="redelegate", user_id="owner", terminal_sessions=grants):
|
||||
pytest.fail("子代理不能转授父任务终端")
|
||||
assert current_terminal_scope() is child
|
||||
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
|
||||
assert current_terminal_scope() is parent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("entry", ["task", "start", "run", "batch", "pipeline"])
|
||||
async def test_registered_task_entries_forward_per_task_terminal_grants(
|
||||
terminal_parent: tuple[_TerminalSessionManager, TerminalScope, str], monkeypatch: pytest.MonkeyPatch, entry: str,
|
||||
) -> None:
|
||||
"""阻塞、异步、批量和管道的真实工具入口都按条目转发且分配独立 task_id。"""
|
||||
manager, parent, session_id = terminal_parent
|
||||
scopes: list[TerminalScope] = []
|
||||
output: list[str] = []
|
||||
|
||||
async def invoke(state: dict[str, Any], **_kwargs: Any) -> dict[str, list[AIMessage]]:
|
||||
"""直接读取真实管理器,授权随任务输入而非同缓存 profile 传播。"""
|
||||
scopes.append(require_terminal_scope())
|
||||
description = state["messages"][0].content
|
||||
if description.startswith("shared"):
|
||||
text = (await manager.read(session_id=session_id))["output"]
|
||||
else:
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await manager.read(session_id=session_id)
|
||||
text = "denied"
|
||||
output.append(text)
|
||||
return {"messages": [AIMessage(content=text)]}
|
||||
|
||||
monkeypatch.setattr(subagent_module, "create_agent", lambda **_kwargs: SimpleNamespace(ainvoke=invoke))
|
||||
middleware_type = MoviePilotSubAgentMiddleware if entry == "task" else SubAgentTaskControlMiddleware
|
||||
middleware = middleware_type(model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
|
||||
tools=[], policy_context=_policy())
|
||||
shared = {"description": "shared", "subagent_type": "general-purpose",
|
||||
"terminal_sessions": [{"session_id": session_id}]}
|
||||
arguments: dict[str, Any] = dict(shared)
|
||||
if entry in {"batch", "pipeline"}:
|
||||
arguments = {"action": "run" if entry == "batch" else "pipeline", "timeout_ms": 1000,
|
||||
"tasks": [shared, {"description": "sibling"}]}
|
||||
elif entry != "task":
|
||||
arguments.update(action=entry, timeout_ms=1000)
|
||||
try:
|
||||
with bind_terminal_scope(parent):
|
||||
result = await middleware.tools[0].ainvoke(arguments)
|
||||
if entry == "start":
|
||||
response = json.loads(result)
|
||||
ids = [task["task_id"] for task in response["tasks"]]
|
||||
result = await middleware.tools[0].ainvoke({"action": "wait", "task_ids": ids, "timeout_ms": 1000})
|
||||
if entry != "task":
|
||||
response = json.loads(result)
|
||||
assert response["success"]
|
||||
assert {scope.task_id for scope in scopes} == {task["task_id"] for task in response["tasks"]}
|
||||
assert current_terminal_scope() is parent and not parent.closed
|
||||
assert "PARENT_ONLY" in (await manager.read(session_id=session_id))["output"]
|
||||
assert "PARENT_ONLY" in output[0]
|
||||
assert len(scopes) == (2 if entry in {"batch", "pipeline"} else 1)
|
||||
assert len({id(scope) for scope in scopes}) == len(scopes)
|
||||
assert all(scope.closed for scope in scopes)
|
||||
if len(scopes) == 2:
|
||||
assert output[1] == "denied"
|
||||
finally:
|
||||
if isinstance(middleware, SubAgentTaskControlMiddleware):
|
||||
assert await middleware.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actions", [["write"], ["interrupt"], ["kill"], ["read", "write"], []])
|
||||
def test_terminal_grant_schema_rejects_process_control(actions: list[str]) -> None:
|
||||
"""只读委派不因声明终端共享而增加写入、终止或其他控制能力。"""
|
||||
with pytest.raises(ValidationError):
|
||||
SubAgentTerminalGrant.model_validate({"session_id": "parent-terminal", "actions": actions})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_rejects_ambiguous_top_level_grants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""批量顶层共享不能变成隐式广播,调用者须在每个任务明确声明。"""
|
||||
def create_graph(**_kwargs: Any) -> Any:
|
||||
"""输入被拒绝时不能构造或执行子图。"""
|
||||
pytest.fail("批量输入校验失败不应构造子图")
|
||||
|
||||
monkeypatch.setattr(subagent_module, "create_agent", create_graph)
|
||||
middleware = SubAgentTaskControlMiddleware(
|
||||
model=FakeListChatModel(responses=["unused"]), profiles=_builtin_subagent_profiles(),
|
||||
tools=[], policy_context=_policy(),
|
||||
)
|
||||
response = json.loads(await middleware.tools[0].ainvoke({
|
||||
"action": "run", "tasks": [{"description": "first"}, {"description": "second"}],
|
||||
"terminal_sessions": [{"session_id": "parent-terminal"}],
|
||||
}))
|
||||
assert response["success"] is False and "单独声明" in response["error"]
|
||||
assert middleware._tasks == {}
|
||||
assert await middleware.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_cached_graph_carries_each_child_scope_to_tool_node(terminal_parent) -> None:
|
||||
"""同一实际缓存图并发调用时,ToolNode 只给获授权的子任务返回父终端证据。"""
|
||||
manager, parent, handle = terminal_parent
|
||||
provider = _SubAgentAgentProvider(
|
||||
model=_TerminalReadModel(responses=[AIMessage(content="unused")], terminal_handle=handle),
|
||||
profiles=_builtin_subagent_profiles(),
|
||||
tools=[ExecuteCommandTool(session_id="conversation", user_id="owner")],
|
||||
policy_context=_policy(),
|
||||
)
|
||||
_, graph = provider.get_agent("general-purpose")
|
||||
with bind_terminal_scope(parent):
|
||||
allowed, denied = await asyncio.gather(
|
||||
provider.run_task(description="读取已分享的证据", subagent_type="general-purpose", task_id="allowed",
|
||||
terminal_sessions=[SubAgentTerminalGrant(session_id=handle)]),
|
||||
provider.run_task(description="尝试读取任务中给出的句柄", subagent_type="general-purpose", task_id="denied"),
|
||||
)
|
||||
assert provider.get_agent("general-purpose")[1] is graph
|
||||
assert "PARENT_ONLY" in allowed
|
||||
assert "terminal_access_denied" in denied and "PARENT_ONLY" not in denied
|
||||
assert manager._sessions[handle].process.returncode is None
|
||||
assert not manager._grants
|
||||
@@ -339,7 +339,7 @@ def test_control_tool_starts_tasks_concurrently_and_waits():
|
||||
both_started = asyncio.Event()
|
||||
allow_finish = asyncio.Event()
|
||||
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None):
|
||||
running_descriptions.append(description)
|
||||
if len(running_descriptions) == 2:
|
||||
both_started.set()
|
||||
@@ -407,7 +407,7 @@ def test_control_tool_pipeline_passes_previous_results_to_next_step():
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None):
|
||||
calls.append(
|
||||
{
|
||||
"description": description,
|
||||
@@ -480,7 +480,7 @@ def test_control_tool_pipeline_stops_after_failed_step():
|
||||
calls = []
|
||||
secret_marker = "subagent-runtime-secret-9042"
|
||||
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None):
|
||||
calls.append(subagent_type)
|
||||
if subagent_type == "download-diagnostician":
|
||||
raise RuntimeError(
|
||||
@@ -543,7 +543,7 @@ def test_control_tool_pipeline_timeout_is_bounded_when_task_ignores_cancel():
|
||||
release = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def _ignore_cancel(self, *, description, subagent_type, task_id=None):
|
||||
async def _ignore_cancel(self, *, description, subagent_type, task_id=None, terminal_sessions=None):
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
@@ -591,7 +591,7 @@ def test_after_agent_cancels_unfinished_tasks():
|
||||
)
|
||||
task_started = asyncio.Event()
|
||||
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
|
||||
async def _fake_run_task(self, *, description, subagent_type, task_id=None, terminal_sessions=None):
|
||||
task_started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -9,15 +9,19 @@ from typing import Any, Optional
|
||||
import pytest
|
||||
|
||||
from app.agent.terminal import session as terminal_state
|
||||
from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.output import TerminalOutputError
|
||||
from app.agent.terminal.ownership import current_terminal_scope
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
from app.agent.tools.result import inspect_tool_result
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
def _world(*, use_pty: bool = True) -> tuple[_TerminalSessionManager, _TerminalSession]:
|
||||
"""仅登记内存记录,不创建 OS 进程,也不调用终止这些虚构 PID 的动作。"""
|
||||
manager = _TerminalSessionManager()
|
||||
session = _TerminalSession(session_id="term-cursor-test", command="memory-only", cwd=".", pid=0, use_pty=use_pty)
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="term-cursor-test", command="memory-only", cwd=".", pid=0, use_pty=use_pty)
|
||||
manager._sessions[session.session_id] = session
|
||||
return manager, session
|
||||
|
||||
|
||||
@@ -17,9 +17,13 @@ import pytest_asyncio
|
||||
|
||||
from app.agent.shell import AgentShell
|
||||
from app.agent.terminal import manager as terminal
|
||||
from app.agent.terminal.manager import TerminalOutputError, _TerminalSessionManager
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.output import TerminalOutputError
|
||||
from app.agent.terminal.ownership import current_terminal_scope
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
def _command(code: str) -> str:
|
||||
"""使用当前虚拟环境,POSIX exec 让被测程序直接拥有会话进程与信号处理器。"""
|
||||
@@ -88,7 +92,7 @@ async def test_pipe_last_input_and_half_close_deliver_all_bytes(manager: _Termin
|
||||
async def test_pty_half_close_rejects_before_input_and_keeps_output_readable(manager: _TerminalSessionManager) -> None:
|
||||
"""不支持的组合不能先写入 BAD,也不能通过关闭 master 破坏输出。"""
|
||||
payload = await _ready(manager, "import sys,tty\ntty.setraw(0)\nprint('READY',flush=True)\ndata=sys.stdin.buffer.read(1)\nprint('GOT:'+str(data[0]),flush=True)", use_pty=True)
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
descriptor = session.master_fd
|
||||
with pytest.raises(ValueError, match="PTY 不支持"):
|
||||
await manager.write(session_id=session.session_id, input_text="BAD", close_stdin=True, **_cursor(payload))
|
||||
@@ -118,7 +122,7 @@ async def test_large_raw_pty_input_retries_short_writes_without_losing_tail(
|
||||
f"os.read({control_read},1)\nos.close({control_read})\ndata=sys.stdin.buffer.read({len(data)})\n"
|
||||
"print('RESULT:'+str(len(data))+':'+hashlib.sha256(data).hexdigest(),flush=True)")
|
||||
payload = await _ready(manager, code, use_pty=True)
|
||||
descriptor = manager.get_session(payload["session_id"]).master_fd
|
||||
descriptor = manager._sessions[payload["session_id"]].master_fd
|
||||
|
||||
def observe_write(fd: int, content: Any) -> int:
|
||||
"""只观察该测试 PTY 的真实写入计数与回压,不替换内核返回值。"""
|
||||
@@ -161,7 +165,7 @@ async def test_interrupt_calls_handler_once_and_program_remains_interactive(mana
|
||||
assert result["signal"] == "SIGINT" and result["signal_sent"] is True
|
||||
output, after = await _read_to(manager, result, "INTERRUPTED")
|
||||
assert output.count("INTERRUPTED") == 1
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
assert session.status == "running" and session.kill_requested is False
|
||||
reply = await manager.write(session_id=session.session_id, input_text="quit\n", **_cursor(after))
|
||||
output, final = await _read_to(manager, reply)
|
||||
@@ -173,7 +177,7 @@ async def test_interrupt_calls_handler_once_and_program_remains_interactive(mana
|
||||
async def test_invalid_signal_has_no_os_or_kill_intent_effect(value: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""未知信号、处理器常量和无效编号必须在任何状态变化之前拒绝。"""
|
||||
current = _TerminalSessionManager()
|
||||
session = _TerminalSession(session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False)
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="signal-validation", command="memory-only", cwd=".", pid=0, use_pty=False)
|
||||
current._sessions[session.session_id] = session
|
||||
send = Mock()
|
||||
monkeypatch.setattr(current, "_send_signal", send)
|
||||
@@ -189,7 +193,7 @@ async def test_concurrent_pipe_write_then_close_serializes_final_segment(
|
||||
) -> None:
|
||||
"""真实管道首段尚未 drain 时,close 请求必须等待输入锁并完整交付末段。"""
|
||||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)")
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
writer = session.process.stdin
|
||||
original_drain = writer.drain
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
@@ -237,7 +241,7 @@ async def test_concurrent_input_after_close_is_rejected_before_writer_receives_i
|
||||
await release.wait()
|
||||
|
||||
writer = SimpleNamespace(write=values.append, drain=AsyncMock(), close=Mock(), wait_closed=wait_closed)
|
||||
session = _TerminalSession(session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False,
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="close-race", command="memory-only", cwd=".", pid=0, use_pty=False,
|
||||
process=SimpleNamespace(stdin=writer))
|
||||
current._sessions[session.session_id] = session
|
||||
close = asyncio.create_task(current.write(session_id=session.session_id, input_text="tail", close_stdin=True))
|
||||
@@ -259,7 +263,7 @@ async def test_concurrent_input_after_close_is_rejected_before_writer_receives_i
|
||||
async def test_input_control_preserves_cursor_validation_and_action_output_error(manager: _TerminalSessionManager) -> None:
|
||||
"""非法游标先于 stdin 动作,已发生 half-close 后的小页错误保留会话和输入事实。"""
|
||||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nprint('DATA:'+sys.stdin.read(),flush=True)")
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
with pytest.raises(TerminalOutputError):
|
||||
await manager.write(session_id=session.session_id, input_text="wrong", close_stdin=True, since_seq=999)
|
||||
assert session.stdin_closed is False
|
||||
@@ -274,7 +278,7 @@ async def test_windows_interrupt_uses_actual_break_event_without_terminate(monke
|
||||
"""Windows 控制事件与终止 API 分开,仅报告实际调用的 CTRL_BREAK_EVENT。"""
|
||||
current = _TerminalSessionManager()
|
||||
process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock())
|
||||
session = _TerminalSession(session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-interrupt", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||||
current._sessions[session.session_id] = session
|
||||
with monkeypatch.context() as scoped:
|
||||
scoped.setattr(terminal.os, "name", "nt")
|
||||
@@ -291,7 +295,7 @@ async def test_windows_missing_control_event_and_unmapped_kill_never_terminate(m
|
||||
"""缺少控制事件或请求未映射的信号时明确失败,不能静默执行 terminate。"""
|
||||
current = _TerminalSessionManager()
|
||||
process = SimpleNamespace(send_signal=Mock(), terminate=Mock(), kill=Mock())
|
||||
session = _TerminalSession(session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="windows-unavailable", command="memory-only", cwd=".", pid=0, use_pty=False, process=process)
|
||||
current._sessions[session.session_id] = session
|
||||
with monkeypatch.context() as scoped:
|
||||
scoped.setattr(terminal.os, "name", "nt")
|
||||
@@ -338,4 +342,4 @@ async def test_pipe_shell_policy_metadata_matches_explicit_non_login_execution(m
|
||||
payload = await _ready(manager, "import sys\nprint('READY',flush=True)\nsys.stdin.read()")
|
||||
if os.name == "posix":
|
||||
assert payload["shell"] == "/bin/sh" and payload["login"] is False
|
||||
assert manager.get_session(payload["session_id"]).shell_policy is not None
|
||||
assert manager._sessions[payload["session_id"]].shell_policy is not None
|
||||
|
||||
@@ -12,9 +12,12 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.agent.terminal import manager as terminal_module
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
_DEADLINE = 5
|
||||
_INTERACTIVE = """import sys
|
||||
print('READY', flush=True)
|
||||
@@ -61,7 +64,7 @@ async def _start_ready(manager: _TerminalSessionManager, code: str = _INTERACTIV
|
||||
env={"PYTHONIOENCODING": "utf-8"},
|
||||
), timeout=_DEADLINE)
|
||||
assert "READY" in payload["output"]
|
||||
session = manager.get_session(payload["session_id"])
|
||||
session = manager._sessions[payload["session_id"]]
|
||||
assert session.process is not None and session.process.returncode is None
|
||||
return payload, session
|
||||
|
||||
@@ -197,7 +200,7 @@ async def test_new_output_wakes_wait_without_process_exit(monkeypatch, terminal_
|
||||
async def test_output_between_empty_snapshot_and_await_is_not_lost(monkeypatch, terminal_manager):
|
||||
"""同步插入恰好发生在空快照之后的输出,等待必须消费已经触发的旧通知事件。"""
|
||||
initial, session = await _start_ready(terminal_manager)
|
||||
original_read = terminal_manager._read_payload
|
||||
original_read = terminal_module.read_payload
|
||||
inserted = False
|
||||
|
||||
def read(current: _TerminalSession, **kwargs: Any) -> dict[str, Any]:
|
||||
@@ -209,7 +212,7 @@ async def test_output_between_empty_snapshot_and_await_is_not_lost(monkeypatch,
|
||||
current.append_output("stdout", b"BETWEEN-CHECK-AND-WAIT\n")
|
||||
return payload
|
||||
|
||||
monkeypatch.setattr(terminal_manager, "_read_payload", read)
|
||||
monkeypatch.setattr(terminal_module, "read_payload", read)
|
||||
payload = await asyncio.wait_for(terminal_manager.wait(
|
||||
session_id=session.session_id, timeout_ms=10000, **_cursor(initial),
|
||||
), timeout=_DEADLINE)
|
||||
@@ -306,7 +309,7 @@ async def test_start_small_legacy_page_preserves_session_handle(terminal_manager
|
||||
payload = await asyncio.wait_for(terminal_manager.start(
|
||||
command=_command(_INTERACTIVE), use_pty=False, yield_time_ms=10000, max_bytes=1,
|
||||
), timeout=_DEADLINE)
|
||||
session = terminal_manager.get_session(payload["session_id"])
|
||||
session = terminal_manager._sessions[payload["session_id"]]
|
||||
assert session.process.returncode is None and payload["status"] == "running"
|
||||
assert payload["output"] == "" and _position(payload) == (0, 0)
|
||||
assert payload["output_error"]["code"]
|
||||
@@ -392,7 +395,7 @@ async def test_long_command_truncates_only_display_and_preserves_real_session(te
|
||||
payload = await asyncio.wait_for(terminal_manager.start(
|
||||
command=command, use_pty=False, yield_time_ms=10000, since_offset=0, max_output_chars=4096,
|
||||
), timeout=_DEADLINE)
|
||||
session = terminal_manager.get_session(payload["session_id"])
|
||||
session = terminal_manager._sessions[payload["session_id"]]
|
||||
assert session.command == command
|
||||
assert payload["command_truncated"] is True and payload["command_total_chars"] == len(command)
|
||||
assert len(json.dumps(payload["command"], ensure_ascii=False)) <= 1024
|
||||
|
||||
275
tests/test_agent_terminal_ownership.py
Normal file
275
tests/test_agent_terminal_ownership.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""终端句柄归属、明确共享和关闭竞态的真实进程验收。"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.agent.terminal import manager as terminal
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalAccessError,
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
close_terminal_scope,
|
||||
current_terminal_scope,
|
||||
require_terminal_scope,
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager() -> AsyncIterator[_TerminalSessionManager]:
|
||||
"""每例仅拥有自己创建的进程;断言失败同样回收测试资源。"""
|
||||
instance = _TerminalSessionManager()
|
||||
try:
|
||||
yield instance
|
||||
finally:
|
||||
await asyncio.wait_for(instance.close(), 15)
|
||||
|
||||
|
||||
def _scope(user: str = "alice", task: str = "task") -> TerminalScope:
|
||||
"""相同可见字段仍创建不同的宿主任务代次。"""
|
||||
return TerminalScope(user_id=user, task_id=task, kind="test")
|
||||
|
||||
|
||||
async def _start(manager: _TerminalSessionManager, owner: TerminalScope, *, use_pty: bool = False) -> dict[str, Any]:
|
||||
"""启动受控程序,以真实输出 READY 确认它正在等待唯一输入。"""
|
||||
code = (
|
||||
"import sys\n"
|
||||
+ ("import tty;tty.setraw(0)\n" if use_pty else "")
|
||||
+ "print('PRIVATE_READY',flush=True)\ndata=sys.stdin.buffer.read(1)\n"
|
||||
+ "print('GOT:'+str(data[0]),flush=True)\n"
|
||||
)
|
||||
args = [sys.executable, "-u", "-c", code]
|
||||
command = subprocess.list2cmdline(args) if os.name == "nt" else "exec " + shlex.join(args)
|
||||
options = {"shell": "/bin/sh", "login": False} if os.name == "posix" else {}
|
||||
with bind_terminal_scope(owner):
|
||||
payload = await manager.start(command=command, use_pty=use_pty, yield_time_ms=10000, **options)
|
||||
assert "PRIVATE_READY" in payload["output"]
|
||||
return payload
|
||||
|
||||
|
||||
async def _finish(manager: _TerminalSessionManager, owner: TerminalScope, handle: str) -> str:
|
||||
"""只消费工具交付游标,确认真实进程读取字节后退出。"""
|
||||
with bind_terminal_scope(owner):
|
||||
payload = await manager.write(session_id=handle, input_text="Z")
|
||||
output = payload["output"]
|
||||
async with asyncio.timeout(5):
|
||||
while not payload["output_complete"]:
|
||||
payload = await manager.wait(
|
||||
session_id=handle, since_seq=payload["output_until_seq"],
|
||||
since_offset=payload["output_until_offset"], timeout_ms=1000,
|
||||
)
|
||||
output += payload["output"]
|
||||
assert payload["exit_code"] == 0
|
||||
return output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_pty", [False, pytest.param(True, marks=pytest.mark.skipif(os.name != "posix", reason="PTY 仅 POSIX"))])
|
||||
@pytest.mark.parametrize("other_user", ["alice", "bob"])
|
||||
async def test_real_handle_is_not_authority(manager: _TerminalSessionManager, use_pty: bool, other_user: str) -> None:
|
||||
"""同名任务的新代次和另一用户均不能通过真实句柄读取、投递输入或控制进程。"""
|
||||
owner, outsider = _scope(), _scope(other_user)
|
||||
payload = await _start(manager, owner, use_pty=use_pty)
|
||||
handle = payload["session_id"]
|
||||
operations = [
|
||||
(manager.read, {}), (manager.wait, {"timeout_ms": 0}),
|
||||
(manager.write, {"input_text": "BAD"}),
|
||||
(manager.write, {"input_text": "", "close_stdin": True}),
|
||||
(manager.interrupt, {}), (manager.kill, {}),
|
||||
]
|
||||
with bind_terminal_scope(outsider):
|
||||
for operation, options in operations:
|
||||
with pytest.raises(TerminalAccessError) as actual:
|
||||
await operation(session_id=handle, **options)
|
||||
with pytest.raises(TerminalAccessError) as missing:
|
||||
await operation(session_id="term_missing", **options)
|
||||
assert str(actual.value) == str(missing.value)
|
||||
assert "PRIVATE_READY" not in str(actual.value)
|
||||
assert manager._sessions[handle].status == "running"
|
||||
assert "GOT:90" in await _finish(manager, owner, handle)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identity_and_empty_scope_are_explicit(manager: _TerminalSessionManager) -> None:
|
||||
"""绑定空上下文允许非终端调用,终端要求可信用户、任务和未封口代次。"""
|
||||
outer = _scope()
|
||||
with bind_terminal_scope(outer):
|
||||
with bind_terminal_scope(None):
|
||||
assert current_terminal_scope() is None
|
||||
with pytest.raises(TerminalAccessError):
|
||||
require_terminal_scope()
|
||||
assert require_terminal_scope() is outer
|
||||
for owner in [_scope(user=""), _scope(task=""), outer]:
|
||||
if owner is outer:
|
||||
owner.seal()
|
||||
with bind_terminal_scope(owner), pytest.raises(TerminalAccessError):
|
||||
await manager.start(command="echo must-not-start")
|
||||
assert not manager._sessions and manager._starting == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_share_is_atomic_specific_and_not_transitive(manager: _TerminalSessionManager) -> None:
|
||||
"""被授予子任务只可读取明确句柄,无效批量不产生部分授权且不能转授给兄弟。"""
|
||||
owner, child, sibling = _scope(), _scope(task="child"), _scope(task="sibling")
|
||||
handle = (await _start(manager, owner))["session_id"]
|
||||
with bind_terminal_scope(owner), pytest.raises(TerminalAccessError):
|
||||
manager.share(owner, child, {handle: frozenset({"read"}), "missing": frozenset({"read"})})
|
||||
assert child not in manager._grants
|
||||
with bind_terminal_scope(owner):
|
||||
manager.share(owner, child, {handle: frozenset({"read", "wait"})})
|
||||
with pytest.raises(TerminalAccessError):
|
||||
manager.share(owner, _scope("bob"), {handle: frozenset({"read"})})
|
||||
with pytest.raises(TerminalAccessError):
|
||||
manager.share(owner, sibling, {handle: frozenset({"start"})})
|
||||
with bind_terminal_scope(child):
|
||||
assert "PRIVATE_READY" in (await manager.read(session_id=handle))["output"]
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await manager.write(session_id=handle, input_text="BAD")
|
||||
with pytest.raises(TerminalAccessError):
|
||||
manager.share(child, sibling, {handle: frozenset({"read"})})
|
||||
assert await manager.close_owner(child)
|
||||
assert manager._sessions[handle].status == "running"
|
||||
assert "GOT:90" in await _finish(manager, owner, handle)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("close_parent", [False, True])
|
||||
async def test_long_wait_wakes_when_either_scope_seals(manager: _TerminalSessionManager, close_parent: bool) -> None:
|
||||
"""等待无输出时封住任一端,不必等满一分钟才反馈授权失效。"""
|
||||
owner, child = _scope(), _scope(task="child")
|
||||
payload = await _start(manager, owner)
|
||||
with bind_terminal_scope(owner):
|
||||
manager.share(owner, child, {payload["session_id"]: frozenset({"wait"})})
|
||||
with bind_terminal_scope(child):
|
||||
pending = asyncio.create_task(manager.wait(
|
||||
session_id=payload["session_id"], timeout_ms=60000,
|
||||
since_seq=payload["output_until_seq"], since_offset=payload["output_until_offset"],
|
||||
))
|
||||
await asyncio.sleep(0)
|
||||
(owner if close_parent else child).seal()
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await asyncio.wait_for(pending, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_input_rechecks_grant_after_lock(manager: _TerminalSessionManager) -> None:
|
||||
"""排队写入在取得输入锁之前已撤销时,不向父进程投递任何字节。"""
|
||||
owner, child = _scope(), _scope(task="child")
|
||||
handle = (await _start(manager, owner))["session_id"]
|
||||
session = manager._sessions[handle]
|
||||
with bind_terminal_scope(owner):
|
||||
manager.share(owner, child, {handle: frozenset({"write"})})
|
||||
await session.input_lock.acquire()
|
||||
with bind_terminal_scope(child):
|
||||
pending = asyncio.create_task(manager.write(session_id=handle, input_text="BAD", close_stdin=True))
|
||||
await asyncio.sleep(0)
|
||||
assert await manager.close_owner(child)
|
||||
session.input_lock.release()
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await pending
|
||||
assert not session.stdin_closed and "GOT:90" in await _finish(manager, owner, handle)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_owner_during_start_retains_other_owner(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""启动已预留但尚未登记时关闭任务,迟到进程被回收且另一个任务仍可继续。"""
|
||||
owner, other = _scope(), _scope(task="other")
|
||||
other_handle = (await _start(manager, other))["session_id"]
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
original = manager._start_pipe_session
|
||||
|
||||
async def delayed(*args: Any, **kwargs: Any) -> Any:
|
||||
"""把真实进程创建卡在预留之后,提供确定性的关闭交错。"""
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(manager, "_start_pipe_session", delayed)
|
||||
pending = asyncio.create_task(_start(manager, owner))
|
||||
await entered.wait()
|
||||
closing = asyncio.create_task(manager.close_owner(owner))
|
||||
await asyncio.sleep(0)
|
||||
assert owner.closed and not closing.done()
|
||||
assert "GOT:90" in await _finish(manager, other, other_handle)
|
||||
release.set()
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await pending
|
||||
assert await closing
|
||||
assert not manager._owner_starts and manager._starting == 0
|
||||
assert all(session.owner is other for session in manager._sessions.values())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_start_keeps_reservation_until_process_reaped(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""两次取消首次等待也不会丢失尚未返回的进程,清理等待启动完成后才释放预留。"""
|
||||
owner = _scope()
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
original = manager._start_pipe_session
|
||||
created = []
|
||||
|
||||
async def delayed(*args: Any, **kwargs: Any) -> Any:
|
||||
"""记录实际已创建的子进程后,阻止句柄提前返回给 start。"""
|
||||
session = await original(*args, **kwargs)
|
||||
created.append(session)
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(manager, "_start_pipe_session", delayed)
|
||||
pending = asyncio.create_task(_start(manager, owner))
|
||||
await entered.wait()
|
||||
pending.cancel()
|
||||
await asyncio.sleep(0)
|
||||
pending.cancel()
|
||||
await asyncio.sleep(0)
|
||||
assert manager._starting == 1 and not pending.done()
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await pending
|
||||
assert not manager._sessions and manager._starting == 0
|
||||
assert created[0].process.returncode is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_nonconvergence_preserves_record_for_retry(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""收尾任务尚未结束不能抹除 owner;重试在进程真实收尾后才确认完成。"""
|
||||
owner = _scope()
|
||||
handle = (await _start(manager, owner))["session_id"]
|
||||
session = manager._sessions[handle]
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(manager, "_wait_for_exit", AsyncMock(return_value=False))
|
||||
patch.setattr(manager, "_send_signal", lambda *_args: None)
|
||||
assert not await manager.close_owner(owner)
|
||||
assert manager._sessions[handle] is session and session.owner is owner and owner.closed
|
||||
assert session.status == "running"
|
||||
assert await manager.close_owner(owner)
|
||||
assert session.process.returncode is not None and handle not in manager._sessions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_cleanup_is_lazy_and_new_manager_rejects_old_handle(manager: _TerminalSessionManager, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""无终端作用域清理不加载实现,重建后的管理器不会凭旧句柄接管或重跑进程。"""
|
||||
unused = _scope(task="unused")
|
||||
with monkeypatch.context() as patch:
|
||||
patch.delitem(sys.modules, "app.agent.terminal.manager")
|
||||
assert await close_terminal_scope(unused)
|
||||
assert "app.agent.terminal.manager" not in sys.modules
|
||||
assert unused.closed
|
||||
owner = _scope()
|
||||
handle = (await _start(manager, owner))["session_id"]
|
||||
fresh = _TerminalSessionManager()
|
||||
with bind_terminal_scope(owner), pytest.raises(TerminalAccessError):
|
||||
await fresh.read(session_id=handle)
|
||||
assert not fresh._sessions
|
||||
monkeypatch.setattr(terminal, "terminal_session_manager", manager)
|
||||
assert await close_terminal_scope(owner)
|
||||
assert handle not in manager._sessions
|
||||
322
tests/test_agent_terminal_scope.py
Normal file
322
tests/test_agent_terminal_scope.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""验证真实 Agent 与会话 worker 的终端归属装配;模型、外部服务均停在调用边界。"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.agent import orchestrator, session
|
||||
from app.agent.manager import AgentManager
|
||||
from app.agent.orchestrator import MoviePilotAgent
|
||||
from app.agent.session import AgentManagerUnavailableError, _MessageTask
|
||||
from app.agent.terminal import manager as terminal
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import (
|
||||
TerminalAccessError,
|
||||
TerminalScope,
|
||||
bind_terminal_scope,
|
||||
current_terminal_scope,
|
||||
require_terminal_scope,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def closed_scopes(monkeypatch) -> list[TerminalScope]:
|
||||
"""只替换进程收口边界,保留真实的宿主 scope 和 worker 生命周期。"""
|
||||
closed = []
|
||||
|
||||
async def close(scope: TerminalScope) -> bool:
|
||||
"""记录真实对象的封口,避免测试触及其他测试持有的全局终端。"""
|
||||
scope.seal()
|
||||
closed.append(scope)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
|
||||
monkeypatch.setattr(session, "close_terminal_scope", close)
|
||||
return closed
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager(closed_scopes) -> AsyncIterator[AgentManager]:
|
||||
"""使用真实消息队列并为每个用例独立收口 worker,记忆边界保持无 I/O。"""
|
||||
memory = AsyncMock()
|
||||
memory.clear_memory = Mock()
|
||||
owner = AgentManager(memory=memory)
|
||||
owner._accepting_tasks = True
|
||||
try:
|
||||
yield owner
|
||||
finally:
|
||||
await owner.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_scope_survives_turn_and_graph_rebuild(monkeypatch, closed_scopes) -> None:
|
||||
"""正常多轮、图失效均复用同一身份,调用结束恢复上层异步上下文。"""
|
||||
agent = MoviePilotAgent("conversation", user_id="alice")
|
||||
seen = []
|
||||
|
||||
async def process(_message: str, **_kwargs) -> str:
|
||||
"""在推理入口读取实际 ContextVar,并使出执行权检验异步传播。"""
|
||||
seen.append(require_terminal_scope())
|
||||
await asyncio.sleep(0)
|
||||
assert require_terminal_scope() is seen[-1]
|
||||
return "完成"
|
||||
|
||||
monkeypatch.setattr(agent, "_process", process)
|
||||
outer = TerminalScope("operator", "parent", "operator")
|
||||
with bind_terminal_scope(outer):
|
||||
assert await agent.process("启动") == "完成"
|
||||
assert current_terminal_scope() is outer
|
||||
assert await agent._invalidate_cached_agent() is True
|
||||
assert await agent.process("继续") == "完成"
|
||||
assert current_terminal_scope() is outer
|
||||
assert seen == [agent._terminal_scope, agent._terminal_scope]
|
||||
assert not closed_scopes
|
||||
assert await agent.cleanup() is True
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await agent.process("清理后不得重新启动")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_agent_can_reason_but_cannot_enter_terminal(monkeypatch) -> None:
|
||||
"""无可信用户的内部 Agent 可执行非终端逻辑,不能获得默认 api_user 身份。"""
|
||||
agent = MoviePilotAgent("anonymous")
|
||||
|
||||
async def process(_message: str, **_kwargs) -> str:
|
||||
"""在真正的 wrapper 中验证缺身份拒绝点。"""
|
||||
assert current_terminal_scope().user_id == ""
|
||||
with pytest.raises(TerminalAccessError):
|
||||
require_terminal_scope()
|
||||
return "无需终端"
|
||||
|
||||
monkeypatch.setattr(agent, "_process", process)
|
||||
assert await agent.process("纯推理") == "无需终端"
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await agent.process("冒用", terminal_scope=TerminalScope("alice", "run", "scheduled"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_seals_all_scopes_and_retries_both_resource_owners(monkeypatch) -> None:
|
||||
"""子代理清理失败不能跳过终端;终端未收敛则保持精确归属对象供重试。"""
|
||||
agent = MoviePilotAgent("conversation", user_id="alice")
|
||||
scheduled = TerminalScope("alice", "run-1", "scheduled")
|
||||
agent._scheduled_terminal_scopes.add(scheduled)
|
||||
children = AsyncMock(side_effect=[False, True, True])
|
||||
monkeypatch.setattr(agent, "_invalidate_cached_agent", children)
|
||||
calls = []
|
||||
|
||||
async def close(scope: TerminalScope) -> bool:
|
||||
"""检查任何第一次异步清理前全部身份均已同步封口。"""
|
||||
assert agent._terminal_scope.closed and scheduled.closed
|
||||
calls.append(scope)
|
||||
return scope is agent._terminal_scope or children.await_count == 3
|
||||
|
||||
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
|
||||
assert await agent.cleanup() is False
|
||||
assert calls == [agent._terminal_scope, scheduled]
|
||||
assert scheduled in agent._scheduled_terminal_scopes
|
||||
assert await agent.cleanup() is False
|
||||
assert await agent.cleanup() is True
|
||||
assert not agent._scheduled_terminal_scopes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_keeps_conversation_and_distinguishes_scheduled_runs(
|
||||
manager, monkeypatch, closed_scopes,
|
||||
) -> None:
|
||||
"""真实队列相同会话的交互轮次共享 owner,两次 scheduled run 各自独立收口。"""
|
||||
seen = []
|
||||
|
||||
async def process(_self, message: str, **_kwargs) -> str:
|
||||
"""在生产 process wrapper 内记录当前实际任务身份。"""
|
||||
seen.append(require_terminal_scope())
|
||||
return message
|
||||
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", process)
|
||||
for message, run_id in [("交互1", None), ("定时1", "run-1"), ("定时2", "run-2"), ("交互2", None)]:
|
||||
assert await manager.process_message(
|
||||
session_id="shared", user_id="alice", message=message,
|
||||
wait_for_completion=True, scheduled_run_id=run_id,
|
||||
) == message
|
||||
assert seen[0] is seen[3]
|
||||
assert seen[1] is not seen[2] and seen[1] is not seen[0]
|
||||
assert [scope.task_id for scope in seen[1:3]] == ["run-1", "run-2"]
|
||||
assert [scope.kind for scope in seen[1:3]] == ["scheduled", "scheduled"]
|
||||
assert seen[1].closed and seen[2].closed
|
||||
assert not seen[0].closed
|
||||
assert closed_scopes == seen[1:3]
|
||||
assert not manager.active_agents["shared"]._scheduled_terminal_scopes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_queued_run_is_sealed_and_never_enters_reasoning(manager, monkeypatch) -> None:
|
||||
"""取消等待中的定时 run 不影响当前交互推理,也不能稍后从队列复活。"""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
messages = []
|
||||
|
||||
async def process(_self, message: str, **_kwargs) -> str:
|
||||
"""仅阻塞第一条交互请求,保留后续定时请求的真实排队窗口。"""
|
||||
messages.append(message)
|
||||
if message == "交互":
|
||||
started.set()
|
||||
await release.wait()
|
||||
return message
|
||||
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", process)
|
||||
interactive = asyncio.create_task(manager.process_message(
|
||||
session_id="shared", user_id="alice", message="交互", wait_for_completion=True,
|
||||
))
|
||||
await asyncio.wait_for(started.wait(), 2)
|
||||
scheduled = asyncio.create_task(manager.process_message(
|
||||
session_id="shared", user_id="alice", message="取消的定时", scheduled_run_id="run-1",
|
||||
wait_for_completion=True,
|
||||
))
|
||||
await asyncio.sleep(0)
|
||||
queued = manager._session_queues["shared"]._queue[0]
|
||||
scheduled.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await scheduled
|
||||
assert queued.terminal_scope.closed
|
||||
assert not manager.active_agents["shared"]._terminal_scope.closed
|
||||
release.set()
|
||||
assert await interactive == "交互"
|
||||
await asyncio.wait_for(manager._session_queues["shared"].join(), 2)
|
||||
assert messages == ["交互"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_active_wait_closes_same_scope_and_retains_failed_cleanup(
|
||||
manager, monkeypatch,
|
||||
) -> None:
|
||||
"""等待者取消先封活动 scope;不收敛时对象留在 Agent,worker 最终再尝试回收。"""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
seen = []
|
||||
closes = []
|
||||
|
||||
async def process(_self, _message: str, **_kwargs) -> str:
|
||||
"""模拟已进入模型且稍后才结束的执行,不让 caller 取消传播到会话 worker。"""
|
||||
seen.append(require_terminal_scope())
|
||||
started.set()
|
||||
await release.wait()
|
||||
with pytest.raises(TerminalAccessError):
|
||||
require_terminal_scope()
|
||||
return "收尾"
|
||||
|
||||
async def close(scope: TerminalScope) -> bool:
|
||||
"""第一次终端收口失败,实际 worker 结束后再报告已收敛。"""
|
||||
scope.seal()
|
||||
closes.append(scope)
|
||||
return release.is_set()
|
||||
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", process)
|
||||
monkeypatch.setattr(orchestrator, "close_terminal_scope", close)
|
||||
scheduled = asyncio.create_task(manager.process_message(
|
||||
session_id="shared", user_id="alice", message="定时", scheduled_run_id="run-1",
|
||||
wait_for_completion=True,
|
||||
))
|
||||
await asyncio.wait_for(started.wait(), 2)
|
||||
scheduled.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await scheduled
|
||||
agent = manager.active_agents["shared"]
|
||||
assert seen[0].closed
|
||||
assert closes == seen
|
||||
assert seen[0] in agent._scheduled_terminal_scopes
|
||||
assert not agent._terminal_scope.closed
|
||||
release.set()
|
||||
await asyncio.wait_for(manager._session_queues["shared"].join(), 2)
|
||||
assert closes == [seen[0], seen[0]]
|
||||
assert not agent._scheduled_terminal_scopes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_user_retains_old_agent_until_cleanup_converges(manager, monkeypatch) -> None:
|
||||
"""同 session 换主体必须等待旧资源收口,不能只修改旧 Agent 的 user_id。"""
|
||||
old = MoviePilotAgent("shared", user_id="alice")
|
||||
manager.active_agents["shared"] = old
|
||||
cleanup = AsyncMock(side_effect=[False, True])
|
||||
monkeypatch.setattr(old, "cleanup", cleanup)
|
||||
process = AsyncMock(return_value="bob 完成")
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", process)
|
||||
task = _MessageTask(session_id="shared", user_id="bob", message="新用户")
|
||||
with pytest.raises(AgentManagerUnavailableError):
|
||||
await manager._process_message_internal(task)
|
||||
assert manager.active_agents["shared"] is old
|
||||
assert old.user_id == "alice"
|
||||
process.assert_not_awaited()
|
||||
assert await manager._process_message_internal(task) == "bob 完成"
|
||||
replacement = manager.active_agents["shared"]
|
||||
assert replacement is not old and replacement.user_id == "bob"
|
||||
assert replacement._terminal_scope is not old._terminal_scope
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_worker_terminal_survives_stop_and_other_user_cleanup(monkeypatch) -> None:
|
||||
"""真实 pipe 经生产 worker 装配 owner,停止推理保留进程,换主体清理才回收。"""
|
||||
terminals = _TerminalSessionManager()
|
||||
monkeypatch.setattr(terminal, "terminal_session_manager", terminals)
|
||||
memory = AsyncMock()
|
||||
memory.clear_memory = Mock()
|
||||
owner = AgentManager(memory=memory)
|
||||
owner._accepting_tasks = True
|
||||
ready = asyncio.Event()
|
||||
handles = []
|
||||
argv = [sys.executable, "-u", "-c", "import sys; print('READY', flush=True); sys.stdin.read()"]
|
||||
command = subprocess.list2cmdline(argv) if os.name == "nt" else "exec " + shlex.join(argv)
|
||||
|
||||
async def process(_self, message: str, **_kwargs) -> str:
|
||||
"""模型边界使用本地固定程序,其余执行、授权和资源回收均为真实实现。"""
|
||||
if message == "启动并继续推理":
|
||||
payload = await terminals.start(
|
||||
command=command, use_pty=False, yield_time_ms=1000, since_offset=0,
|
||||
)
|
||||
handles.append(payload["session_id"])
|
||||
ready.set()
|
||||
await asyncio.Event().wait()
|
||||
if message == "新用户":
|
||||
return "新用户就绪"
|
||||
return (await terminals.read(session_id=handles[0], since_seq=0, since_offset=0))["output"]
|
||||
|
||||
monkeypatch.setattr(MoviePilotAgent, "_process", process)
|
||||
execution = asyncio.create_task(owner.process_message(
|
||||
session_id="alice-session", user_id="alice", message="启动并继续推理", wait_for_completion=True,
|
||||
))
|
||||
try:
|
||||
await asyncio.wait_for(ready.wait(), 5)
|
||||
alice = owner.active_agents["alice-session"]
|
||||
process_record = terminals._sessions[handles[0]]
|
||||
assert await owner.stop_current_task("alice-session") is True
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await execution
|
||||
assert not alice._terminal_scope.closed
|
||||
assert process_record.process.returncode is None
|
||||
assert await alice._invalidate_cached_agent() is True
|
||||
assert "READY" in await owner.process_message(
|
||||
session_id="alice-session", user_id="alice", message="读取", wait_for_completion=True,
|
||||
)
|
||||
with pytest.raises(TerminalAccessError):
|
||||
await owner.process_message(
|
||||
session_id="bob-session", user_id="bob", message="读取", wait_for_completion=True,
|
||||
)
|
||||
await owner.clear_session("bob-session", "bob")
|
||||
assert process_record.process.returncode is None
|
||||
assert await owner.process_message(
|
||||
session_id="alice-session", user_id="bob", message="新用户", wait_for_completion=True,
|
||||
) == "新用户就绪"
|
||||
assert alice._terminal_scope.closed
|
||||
assert process_record.process.returncode is not None
|
||||
assert handles[0] not in terminals._sessions
|
||||
finally:
|
||||
if not execution.done():
|
||||
execution.cancel()
|
||||
await asyncio.gather(execution, return_exceptions=True)
|
||||
await owner.close()
|
||||
await terminals.close()
|
||||
@@ -12,11 +12,14 @@ from app.agent.middleware.output import ToolOutputMiddleware
|
||||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||||
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||||
from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.terminal.ownership import current_terminal_scope
|
||||
from app.agent.terminal.session import _TerminalSession
|
||||
from app.agent.tools.base import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||
from app.agent.tools.impl import execute_command as command_module
|
||||
from app.agent.tools.impl.execute_command import ExecuteCommandInput, ExecuteCommandTool
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("terminal_scope")
|
||||
|
||||
|
||||
class _PageModel(FakeMessagesListChatModel):
|
||||
"""只固定两轮工具协议,真实分页和外层预算均由生产实现执行。"""
|
||||
@@ -31,7 +34,7 @@ class _PageModel(FakeMessagesListChatModel):
|
||||
async def test_terminal_page_or_error_reaches_model_without_outer_truncation(monkeypatch, partial):
|
||||
"""大转义正文保留真实消费游标,小页错误也必须保持失败状态及恢复参数。"""
|
||||
manager = _TerminalSessionManager()
|
||||
session = _TerminalSession(session_id="term-page-test", command="completed command", cwd=".", pid=123456789, use_pty=False)
|
||||
session = _TerminalSession(owner=current_terminal_scope(), session_id="term-page-test", command="completed command", cwd=".", pid=123456789, use_pty=False)
|
||||
text = "\\\n\"\t" * 20000
|
||||
session.append_output("stdout", text.encode("utf-8"))
|
||||
session.mark_finished(0)
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.agent.terminal.manager import _TerminalSessionManager
|
||||
from app.agent.tools.impl import execute_command as command_module
|
||||
from app.agent.tools.impl.execute_command import MAX_OUTPUT_PREVIEW_BYTES, ExecuteCommandTool
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.usefixtures("terminal_scope")]
|
||||
|
||||
|
||||
def _python_command(code: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user