1268b67223
Cursor-agent puts prompts on argv, so oversized company/report handoffs hit OS ARG_MAX, crash the card as FAILED, and reconcile kept minting new Report attempts forever. Spill large Cursor prompts to a workspace file and hold the report chain after consecutive failures.
676 lines
26 KiB
Python
676 lines
26 KiB
Python
"""Cursor adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from opc.core.models import AgentStatus, Task, TaskResult, TaskStatus
|
|
from opc.layer3_agent.adapters.base import ExternalAgentAdapter, ExternalApprovalRequest
|
|
|
|
|
|
class CursorAdapter(ExternalAgentAdapter):
|
|
"""Invokes local Cursor for programming tasks via CLI."""
|
|
|
|
agent_type = "cursor"
|
|
default_command = "cursor-agent"
|
|
# cursor-agent takes the prompt as a positional argv token (not stdin in
|
|
# --print mode). Large company/report prompts routinely exceed OS ARG_MAX
|
|
# and crash spawn with ``OSError: [Errno 7] Argument list too long``. Keep
|
|
# small prompts on argv; spill larger ones to a workspace file and pass a
|
|
# short pointer prompt instead.
|
|
_ARGV_PROMPT_MAX_BYTES = 16 * 1024
|
|
|
|
def __init__(self, config=None) -> None:
|
|
super().__init__(config=config)
|
|
self._process: asyncio.subprocess.Process | None = None
|
|
self._thinking_buffers: dict[str, list[str]] = {}
|
|
|
|
def resolve_binary(self) -> str | None:
|
|
if not self.config.enabled:
|
|
return None
|
|
for candidate in self._candidate_commands():
|
|
resolved = shutil.which(candidate)
|
|
if not resolved:
|
|
continue
|
|
if candidate == "cursor":
|
|
# The editor CLI is not sufficient for headless agent execution.
|
|
continue
|
|
return resolved
|
|
return None
|
|
|
|
def _runtime_command(self) -> str | None:
|
|
for candidate in self._candidate_commands():
|
|
if candidate == "cursor":
|
|
continue
|
|
if shutil.which(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
def _candidate_commands(self) -> list[str]:
|
|
configured = str(self.configured_command() or "").strip()
|
|
candidates: list[str] = []
|
|
if configured:
|
|
if configured == "cursor":
|
|
candidates.append("cursor-agent")
|
|
candidates.append(configured)
|
|
else:
|
|
candidates.extend(["cursor-agent", "cursor"])
|
|
if "cursor-agent" not in candidates:
|
|
candidates.insert(0, "cursor-agent")
|
|
return list(dict.fromkeys(candidates))
|
|
|
|
async def is_available(self) -> bool:
|
|
return self.resolve_binary() is not None
|
|
|
|
async def get_status(self) -> AgentStatus:
|
|
if self._process and self._process.returncode is None:
|
|
return AgentStatus.RUNNING
|
|
return AgentStatus.IDLE
|
|
|
|
def supports_interactive(self) -> bool:
|
|
return self._runtime_command() is not None
|
|
|
|
def supports_session_resume(self) -> bool:
|
|
return True
|
|
|
|
def supports_approval_prompt_handling(
|
|
self,
|
|
cmd: list[str],
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""Cursor's stream-json interaction events are not stdin prompts.
|
|
|
|
Cursor emits ``interaction_query`` request/response JSON for internal
|
|
tool approvals, and the CLI answers those events itself. Treating that
|
|
stream as a generic stdin approval prompt creates stale OpenOPC cards.
|
|
"""
|
|
_ = cmd
|
|
_ = metadata
|
|
return False
|
|
|
|
def agent_isolation_home_slug(self) -> str:
|
|
return "cursor"
|
|
|
|
def _prompt_arg_for_invocation(
|
|
self,
|
|
prompt: str,
|
|
*,
|
|
workspace_path: str | None = None,
|
|
task: Task | None = None,
|
|
) -> tuple[str, dict[str, object]]:
|
|
"""Return argv-safe prompt text plus transport metadata.
|
|
|
|
Why file spill exists: ``cursor-agent -p`` consumes the prompt as a
|
|
positional CLI argument. Putting a 100k+ company handoff prompt on
|
|
argv trips Linux/macOS ``Argument list too long`` before the process
|
|
starts, which previously crashed report cards and triggered an
|
|
infinite Report #N reconcile storm.
|
|
"""
|
|
prompt_text = str(prompt or "")
|
|
prompt_bytes = len(prompt_text.encode("utf-8"))
|
|
if prompt_bytes <= self._ARGV_PROMPT_MAX_BYTES:
|
|
return prompt_text, {
|
|
"prompt_transport": "argv",
|
|
"prompt_bytes": prompt_bytes,
|
|
}
|
|
|
|
root = Path(str(workspace_path or "").strip() or ".").expanduser()
|
|
try:
|
|
root = root.resolve()
|
|
except OSError:
|
|
root = Path(".").resolve()
|
|
prompt_dir = root / ".opc" / "external_prompts"
|
|
prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
task_id = str(getattr(task, "id", "") or "").strip() or "task"
|
|
digest = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest()[:16]
|
|
prompt_path = prompt_dir / f"cursor_{task_id}_{digest}.md"
|
|
prompt_path.write_text(prompt_text, encoding="utf-8")
|
|
pointer = (
|
|
"Open and follow the complete task instructions in this file exactly:\n"
|
|
f"{prompt_path}\n\n"
|
|
"Treat the file contents as your full prompt. Do not ask for confirmation "
|
|
"before starting; do not recreate the file."
|
|
)
|
|
return pointer, {
|
|
"prompt_transport": "file",
|
|
"prompt_bytes": prompt_bytes,
|
|
"prompt_file": str(prompt_path),
|
|
"prompt_transport_reason": "prompt_too_large_for_argv",
|
|
}
|
|
|
|
@staticmethod
|
|
def _redact_prompt_arg(cmd: list[str], prompt: str) -> list[str]:
|
|
redacted = list(cmd)
|
|
if redacted:
|
|
redacted[-1] = f"<prompt:{len(prompt.encode('utf-8'))}-bytes>"
|
|
return redacted
|
|
|
|
def build_invocation(
|
|
self,
|
|
task: Task,
|
|
workspace_path: str | None = None,
|
|
) -> tuple[list[str], dict[str, object]]:
|
|
full_prompt = self.build_task_prompt(task)
|
|
prompt_arg, transport_meta = self._prompt_arg_for_invocation(
|
|
full_prompt,
|
|
workspace_path=workspace_path,
|
|
task=task,
|
|
)
|
|
command = self._runtime_command() or self.configured_command()
|
|
cmd = [
|
|
command,
|
|
"-p",
|
|
"--output-format",
|
|
"text",
|
|
*self._build_workspace_trust_args(),
|
|
*self._build_approval_args(),
|
|
*self._build_model_args(),
|
|
*self._build_session_args(),
|
|
*list(self.config.extra_args),
|
|
prompt_arg,
|
|
]
|
|
# Redact large/file-backed prompts from audit command strings so logs
|
|
# stay small and never re-inflate ARG_MAX-sized text into metadata.
|
|
display_cmd = (
|
|
self._redact_prompt_arg(cmd, full_prompt)
|
|
if transport_meta.get("prompt_transport") == "file" or len(full_prompt) > 160
|
|
else cmd
|
|
)
|
|
metadata = self.build_invocation_metadata(display_cmd)
|
|
metadata["binary"] = command
|
|
metadata.update(transport_meta)
|
|
return cmd, metadata
|
|
|
|
def build_interactive_invocation(
|
|
self,
|
|
task: Task,
|
|
workspace_path: str | None = None,
|
|
) -> tuple[list[str], dict[str, object]]:
|
|
full_prompt = self.build_task_prompt(task)
|
|
prompt_arg, transport_meta = self._prompt_arg_for_invocation(
|
|
full_prompt,
|
|
workspace_path=workspace_path,
|
|
task=task,
|
|
)
|
|
command = self._runtime_command() or self.configured_command()
|
|
cmd = [
|
|
command,
|
|
"-p",
|
|
"--output-format",
|
|
"stream-json",
|
|
*self._build_workspace_trust_args(),
|
|
*self._build_approval_args(),
|
|
*self._build_model_args(),
|
|
*self._build_session_args(),
|
|
*list(self.config.extra_args),
|
|
prompt_arg,
|
|
]
|
|
display_cmd = (
|
|
self._redact_prompt_arg(cmd, full_prompt)
|
|
if transport_meta.get("prompt_transport") == "file" or len(full_prompt) > 160
|
|
else cmd
|
|
)
|
|
metadata = self.build_invocation_metadata(display_cmd)
|
|
metadata["binary"] = command
|
|
metadata.update(transport_meta)
|
|
return cmd, metadata
|
|
|
|
def extract_resume_session_id(self, output: str) -> str:
|
|
for line in output.splitlines():
|
|
event = self._parse_json_line(line)
|
|
if not isinstance(event, dict):
|
|
continue
|
|
token = self._session_id_from_event(event)
|
|
if token:
|
|
return token
|
|
return super().extract_resume_session_id(output)
|
|
|
|
def normalize_result_output(self, output: str) -> str:
|
|
last_result = ""
|
|
last_assistant = ""
|
|
for line in output.splitlines():
|
|
event = self._parse_json_line(line)
|
|
if not isinstance(event, dict):
|
|
continue
|
|
event_type = str(event.get("type") or event.get("event") or "").strip()
|
|
if event_type == "result":
|
|
text = self._event_text(event)
|
|
if text:
|
|
last_result = text
|
|
elif self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
|
text = self._event_text(event)
|
|
if text:
|
|
last_assistant = text
|
|
return last_result or last_assistant or output
|
|
|
|
def format_progress_update(self, text: str, stream_name: str) -> str | None:
|
|
if stream_name != "stdout":
|
|
stripped = str(text or "").strip()
|
|
return f"[External:{self.agent_type}:stderr] {stripped[:500]}" if stripped else None
|
|
|
|
event = self._parse_json_line(text)
|
|
if not isinstance(event, dict):
|
|
return super().format_progress_update(text, stream_name)
|
|
|
|
event_type = str(event.get("type") or event.get("event") or "").strip()
|
|
if event_type in {"system", "init", "session"}:
|
|
session_id = self._session_id_from_event(event)
|
|
return (
|
|
f"[External:{self.agent_type}:init] session={session_id[:8]}"
|
|
if session_id
|
|
else None
|
|
)
|
|
if "approval" in event_type or "permission" in event_type:
|
|
return None
|
|
if "tool" in event_type or "command" in event_type:
|
|
summary = self._tool_summary(event)
|
|
return f"[External:{self.agent_type}:tool] {summary}" if summary else None
|
|
if event_type == "thinking":
|
|
return self._format_thinking_progress(event)
|
|
if event_type == "result":
|
|
result = self._event_text(event)
|
|
return f"[External:{self.agent_type}:thinking] {result[:2400]}" if result else None
|
|
if self._event_role(event) == "assistant" or event_type in {"assistant", "assistant_message"}:
|
|
message = self._event_text(event)
|
|
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
|
return None
|
|
|
|
def parse_approval_request(
|
|
self,
|
|
text: str,
|
|
stream_name: str,
|
|
) -> ExternalApprovalRequest | None:
|
|
event = self._parse_json_line(text)
|
|
if isinstance(event, dict):
|
|
event_type = str(event.get("type") or event.get("event") or "").strip()
|
|
# Cursor stream-json uses these event types for normal execution.
|
|
# Some payloads contain words such as "approved" or "allow" in web
|
|
# content, so the generic parser must not infer an OpenOPC approval
|
|
# card from them.
|
|
if event_type in {
|
|
"assistant",
|
|
"assistant_message",
|
|
"interaction_query",
|
|
"result",
|
|
"system",
|
|
"thinking",
|
|
"tool_call",
|
|
"user",
|
|
}:
|
|
return None
|
|
return super().parse_approval_request(text, stream_name)
|
|
|
|
async def execute(self, task: Task, workspace_path: str) -> TaskResult:
|
|
if not await self.is_available():
|
|
return TaskResult(status=TaskStatus.FAILED, content="Cursor agent CLI not found")
|
|
cmd, metadata = self.build_invocation(task, workspace_path=workspace_path)
|
|
|
|
logger.info(f"Cursor executing: {task.title}")
|
|
|
|
try:
|
|
stdin_policy = self.stdin_policy_for_process(cmd, metadata)
|
|
self._record_stdin_policy_metadata(metadata, stdin_policy)
|
|
self._process = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdin=self._stdin_target_for_policy(stdin_policy),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=workspace_path,
|
|
**self._subprocess_group_kwargs(),
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(
|
|
self._process.communicate(), timeout=600
|
|
)
|
|
|
|
output = stdout.decode("utf-8", errors="replace")
|
|
errors = stderr.decode("utf-8", errors="replace")
|
|
|
|
if self._process.returncode == 0:
|
|
return TaskResult(status=TaskStatus.DONE, content=output, artifacts=metadata)
|
|
else:
|
|
return TaskResult(
|
|
status=TaskStatus.FAILED,
|
|
content=f"Cursor exited with code {self._process.returncode}\n{errors}\n{output}",
|
|
artifacts=metadata,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
if self._process:
|
|
self._process.kill()
|
|
return TaskResult(
|
|
status=TaskStatus.FAILED,
|
|
content="Cursor timed out after 600s",
|
|
artifacts=metadata,
|
|
)
|
|
except Exception as e:
|
|
return TaskResult(
|
|
status=TaskStatus.FAILED,
|
|
content=f"Cursor error: {e}",
|
|
artifacts=metadata,
|
|
)
|
|
finally:
|
|
self._process = None
|
|
|
|
async def cancel(self, task_id: str) -> bool:
|
|
if self._process and self._process.returncode is None:
|
|
self._process.kill()
|
|
return True
|
|
return False
|
|
|
|
def _build_model_args(self) -> list[str]:
|
|
if not self.config.model:
|
|
return []
|
|
extra_args = list(self.config.extra_args)
|
|
if self.config.model_flag and any(
|
|
arg == self.config.model_flag or arg.startswith(f"{self.config.model_flag}=")
|
|
for arg in extra_args
|
|
):
|
|
return []
|
|
flag = self.config.model_flag or "-m"
|
|
return [flag, self.config.model]
|
|
|
|
def _build_session_args(self) -> list[str]:
|
|
extra_args = list(self.config.extra_args)
|
|
if any(
|
|
arg == "--resume" or arg.startswith("--resume=")
|
|
for arg in extra_args
|
|
):
|
|
return []
|
|
|
|
mode = str(self.config.session_mode or "auto").strip().lower()
|
|
if mode == "resume":
|
|
session_id = str(self.config.session_id or "").strip()
|
|
if session_id:
|
|
return ["--resume", session_id]
|
|
return []
|
|
if mode == "new" and self.config.new_session_flag:
|
|
return [self.config.new_session_flag]
|
|
return []
|
|
|
|
def _build_workspace_trust_args(self) -> list[str]:
|
|
extra_args = list(self.config.extra_args)
|
|
if any(arg in {"--trust", "--yolo", "-f", "--force"} for arg in extra_args):
|
|
return []
|
|
return ["--trust"]
|
|
|
|
def _build_approval_args(self) -> list[str]:
|
|
extra_args = list(self.config.extra_args)
|
|
if any(arg in {"-f", "--force"} for arg in extra_args):
|
|
return []
|
|
|
|
mode = str(self.config.approval_mode or "auto").strip().lower()
|
|
if mode == "full-auto":
|
|
return ["--force"]
|
|
return []
|
|
|
|
@classmethod
|
|
def _session_id_from_event(cls, event: dict[str, Any]) -> str:
|
|
for key in ("session_id", "sessionId", "sessionID", "chat_id", "chatId", "conversation_id", "thread_id"):
|
|
token = str(event.get(key) or "").strip()
|
|
if token:
|
|
return token
|
|
for key in ("message", "data", "result"):
|
|
nested = event.get(key)
|
|
if isinstance(nested, dict):
|
|
token = cls._session_id_from_event(nested)
|
|
if token:
|
|
return token
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _event_role(event: dict[str, Any]) -> str:
|
|
role = str(event.get("role") or "").strip().lower()
|
|
if role:
|
|
return role
|
|
message = event.get("message")
|
|
if isinstance(message, dict):
|
|
return str(message.get("role") or "").strip().lower()
|
|
return ""
|
|
|
|
@classmethod
|
|
def _event_text(cls, event: Any) -> str:
|
|
if isinstance(event, str):
|
|
return event.strip()
|
|
if isinstance(event, list):
|
|
parts = [cls._event_text(item) for item in event]
|
|
return "\n".join(part for part in parts if part).strip()
|
|
if not isinstance(event, dict):
|
|
return ""
|
|
|
|
for key in ("result", "text", "message", "content", "summary"):
|
|
value = event.get(key)
|
|
if key == "message" and isinstance(value, dict):
|
|
nested = cls._event_text(value)
|
|
if nested:
|
|
return nested
|
|
elif isinstance(value, (str, list, dict)):
|
|
nested = cls._event_text(value)
|
|
if nested:
|
|
return nested
|
|
return ""
|
|
|
|
@classmethod
|
|
def _tool_summary(cls, event: dict[str, Any]) -> str:
|
|
source = event.get("tool_call") if isinstance(event.get("tool_call"), dict) else event
|
|
nested_name, nested_payload = cls._nested_cursor_tool_payload(source)
|
|
if nested_payload:
|
|
return cls._nested_cursor_tool_summary(nested_name, nested_payload)
|
|
|
|
name = str(
|
|
source.get("name")
|
|
or source.get("tool_name")
|
|
or source.get("toolName")
|
|
or source.get("type")
|
|
or "tool"
|
|
).strip()
|
|
args = source.get("input") or source.get("arguments") or source.get("params") or {}
|
|
if isinstance(args, dict):
|
|
command = cls.normalize_shell_command(
|
|
args.get("command") or args.get("cmd") or args.get("argv")
|
|
)
|
|
if command:
|
|
return f"$ {command[:240]}"
|
|
path = str(args.get("path") or args.get("file_path") or args.get("target") or "").strip()
|
|
if path:
|
|
return f"{name} {path}"
|
|
return name
|
|
|
|
@staticmethod
|
|
def _nested_cursor_tool_payload(source: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
for key, value in source.items():
|
|
if not isinstance(value, dict):
|
|
continue
|
|
normalized = key[:-8] if key.endswith("ToolCall") else key
|
|
if key.endswith("ToolCall") or "args" in value or "result" in value:
|
|
return normalized, value
|
|
return "", {}
|
|
|
|
@classmethod
|
|
def _nested_cursor_tool_summary(cls, name: str, payload: dict[str, Any]) -> str:
|
|
args = payload.get("args") if isinstance(payload.get("args"), dict) else {}
|
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
|
lines: list[str] = []
|
|
normalized_name = cls._normalize_name(name)
|
|
readable_name = cls._readable_tool_name(name)
|
|
command = cls.normalize_shell_command(
|
|
args.get("command") or args.get("cmd") or args.get("argv")
|
|
)
|
|
if command:
|
|
lines.append(f"$ {command[:240]}")
|
|
elif normalized_name in {"websearch", "websearchrequest"}:
|
|
query = str(args.get("searchTerm") or args.get("query") or args.get("q") or "").strip()
|
|
lines.append(f"web search: {query}".strip())
|
|
elif normalized_name in {"webfetch", "webfetchrequest"}:
|
|
url = str(args.get("url") or args.get("uri") or args.get("target") or "").strip()
|
|
lines.append(f"web fetch: {url}".strip())
|
|
elif name:
|
|
target = str(
|
|
args.get("path")
|
|
or args.get("filePath")
|
|
or args.get("targetDirectory")
|
|
or args.get("target")
|
|
or args.get("globPattern")
|
|
or ""
|
|
).strip()
|
|
lines.append(f"{readable_name} {target}".strip())
|
|
|
|
description = str(payload.get("description") or args.get("description") or "").strip()
|
|
if description and (not lines or description not in lines[0]):
|
|
lines.append(description[:240])
|
|
|
|
result_text = cls._cursor_result_text(result, tool_name=name)
|
|
if result_text:
|
|
lines.append(result_text[:1200])
|
|
return "\n".join(line for line in lines if line).strip() or readable_name or "tool"
|
|
|
|
@classmethod
|
|
def _cursor_result_text(cls, result: dict[str, Any], tool_name: str = "") -> str:
|
|
if not result:
|
|
return ""
|
|
success = result.get("success") if isinstance(result.get("success"), dict) else None
|
|
rejected = result.get("rejected") if isinstance(result.get("rejected"), dict) else None
|
|
error = result.get("error") if isinstance(result.get("error"), dict) else None
|
|
payload = success or rejected or error or result
|
|
normalized_name = cls._normalize_name(tool_name)
|
|
if rejected is not None:
|
|
command = str(payload.get("command") or "").strip()
|
|
reason = str(payload.get("reason") or "rejected").strip()
|
|
return f"rejected: {command or reason}".strip()
|
|
if normalized_name in {"websearch", "websearchrequest"}:
|
|
return cls._summarize_web_search_result(payload)
|
|
if normalized_name in {"webfetch", "webfetchrequest"}:
|
|
return cls._summarize_web_fetch_result(payload)
|
|
for key in ("stdout", "output", "text", "content", "message"):
|
|
value = payload.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
files = payload.get("files")
|
|
if isinstance(files, list):
|
|
shown = [str(item) for item in files[:20]]
|
|
total = payload.get("totalFiles")
|
|
suffix = f" ({total} total)" if total is not None else ""
|
|
return "\n".join(shown) + suffix
|
|
if error is not None:
|
|
return f"error: {payload}"
|
|
return ""
|
|
|
|
def _format_thinking_progress(self, event: dict[str, Any]) -> str | None:
|
|
subtype = str(event.get("subtype") or event.get("status") or "").strip().lower()
|
|
message = self._event_text(event)
|
|
key = self._thinking_buffer_key(event)
|
|
if subtype == "delta":
|
|
delta = self._thinking_delta_text(event)
|
|
if delta:
|
|
self._thinking_buffers.setdefault(key, []).append(delta)
|
|
return None
|
|
if subtype in {"completed", "complete", "done"}:
|
|
buffered = "".join(self._thinking_buffers.pop(key, []))
|
|
message = (message or buffered).strip()
|
|
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
|
return f"[External:{self.agent_type}:thinking] {message[:2400]}" if message else None
|
|
|
|
@staticmethod
|
|
def _thinking_delta_text(event: dict[str, Any]) -> str:
|
|
value = event.get("text")
|
|
if isinstance(value, str):
|
|
return value
|
|
return CursorAdapter._event_text(event)
|
|
|
|
@classmethod
|
|
def _thinking_buffer_key(cls, event: dict[str, Any]) -> str:
|
|
parts = [
|
|
str(event.get(key) or "").strip()
|
|
for key in ("session_id", "sessionId", "sessionID", "model_call_id", "request_id")
|
|
if str(event.get(key) or "").strip()
|
|
]
|
|
return ":".join(parts) or "default"
|
|
|
|
@staticmethod
|
|
def _readable_tool_name(name: str) -> str:
|
|
raw = str(name or "tool").strip()
|
|
raw = raw[:-8] if raw.endswith("ToolCall") else raw
|
|
spaced = re.sub(r"(?<!^)(?=[A-Z])", " ", raw).strip().lower()
|
|
return spaced or "tool"
|
|
|
|
@classmethod
|
|
def _summarize_web_search_result(cls, payload: dict[str, Any]) -> str:
|
|
references = payload.get("references")
|
|
if not isinstance(references, list):
|
|
return cls._summarize_markdown_text(payload.get("markdown") or payload.get("text") or "")
|
|
|
|
lines: list[str] = []
|
|
for ref in references[:5]:
|
|
if not isinstance(ref, dict):
|
|
continue
|
|
title = str(ref.get("title") or "").strip()
|
|
url = str(ref.get("url") or "").strip()
|
|
chunk = str(ref.get("chunk") or "").strip()
|
|
if title and title.lower() != "web search results" and url:
|
|
lines.append(f"- {title} — {url}")
|
|
elif title and title.lower() != "web search results":
|
|
lines.append(f"- {title}")
|
|
elif url:
|
|
lines.append(f"- {url}")
|
|
else:
|
|
lines.extend(cls._extract_markdown_links(chunk, limit=max(0, 5 - len(lines))))
|
|
if len(lines) >= 5:
|
|
break
|
|
|
|
if lines:
|
|
return "\n".join(lines[:5])
|
|
chunks = [
|
|
cls._summarize_markdown_text(str(ref.get("chunk") or ""))
|
|
for ref in references[:2]
|
|
if isinstance(ref, dict)
|
|
]
|
|
return "\n".join(chunk for chunk in chunks if chunk).strip()
|
|
|
|
@classmethod
|
|
def _summarize_web_fetch_result(cls, payload: dict[str, Any]) -> str:
|
|
markdown = str(
|
|
payload.get("markdown")
|
|
or payload.get("content")
|
|
or payload.get("text")
|
|
or ""
|
|
).strip()
|
|
return cls._summarize_markdown_text(markdown)
|
|
|
|
@staticmethod
|
|
def _extract_markdown_links(text: str, limit: int = 5) -> list[str]:
|
|
if limit <= 0:
|
|
return []
|
|
links: list[str] = []
|
|
for title, url in re.findall(r"\[[^\]\n]*?([^\]\n]+)\]\((https?://[^)\s]+)\)", text):
|
|
clean_title = str(title or "").strip()
|
|
clean_url = str(url or "").strip()
|
|
if clean_title and clean_url:
|
|
links.append(f"- {clean_title} — {clean_url}")
|
|
if len(links) >= limit:
|
|
break
|
|
return links
|
|
|
|
@staticmethod
|
|
def _summarize_markdown_text(text: str, max_lines: int = 8) -> str:
|
|
lines: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw_line in str(text or "").splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
if len(line) > 180:
|
|
line = line[:177].rstrip() + "..."
|
|
fingerprint = line.lower()
|
|
if fingerprint in seen:
|
|
continue
|
|
seen.add(fingerprint)
|
|
lines.append(line)
|
|
if len(lines) >= max_lines:
|
|
break
|
|
return "\n".join(lines)
|