Install and update artemis-use with task diagnostics and plugin guidance. Preserve cross-device file receipts, review canonical reports, guard stage transitions and reduce small history summaries. Improve exploration lifecycle, request recovery and browser live connections.
240 lines
9.6 KiB
Python
240 lines
9.6 KiB
Python
"""Small, credential-free request snapshots in the existing task trace store."""
|
|
|
|
import base64
|
|
from contextvars import ContextVar
|
|
from copy import deepcopy
|
|
import io
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
import time
|
|
from uuid import uuid4
|
|
|
|
from PIL import Image
|
|
|
|
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
|
|
from artemis.llm.usage import usage_from_message
|
|
from artemis.llm.stream_guard import ToolArgumentOverflow
|
|
|
|
logger = logging.getLogger(__name__)
|
|
ACTIVE_ATTEMPT: ContextVar["RequestAttempt | None"] = ContextVar("llm_attempt", default=None)
|
|
|
|
|
|
def error_text(error: BaseException, timeout: float | None = None) -> str:
|
|
if isinstance(error, TimeoutError):
|
|
return f"模型请求在 {timeout:g} 秒内未完成" if timeout else "模型请求超时"
|
|
text = str(error).strip() or type(error).__name__
|
|
text = re.sub(
|
|
r"(?i)(bearer\s+|(?:api[_-]?key|token|authorization)[\"']?\s*[:=]\s*[\"']?)[^\s\"',;]+",
|
|
r"\1[REDACTED]",
|
|
text,
|
|
)
|
|
text = re.sub(r"\bsk-[\w-]+", "[REDACTED]", text)
|
|
text = re.sub(r"https?://[^\s\"']+", "[endpoint]", text)
|
|
return text[:1000]
|
|
|
|
|
|
def input_images(args: tuple, kwargs: dict) -> list[dict]:
|
|
"""Inspect only inline image headers; never fetch URLs or retain image data."""
|
|
images = []
|
|
messages = args[0] if args else kwargs.get("input", [])
|
|
if not isinstance(messages, (list, tuple)):
|
|
return images
|
|
for message in messages:
|
|
content = getattr(message, "content", None)
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if not isinstance(block, dict) or block.get("type") not in {
|
|
"image_url",
|
|
"input_image",
|
|
"image",
|
|
}:
|
|
continue
|
|
value = block.get("image_url", block.get("url", ""))
|
|
value = value.get("url", "") if isinstance(value, dict) else value
|
|
info = {"width": None, "height": None}
|
|
if isinstance(value, str) and value.startswith("data:image/") and ";base64," in value:
|
|
try:
|
|
# JPEG/PNG dimensions normally fit in this prefix. Unknown is
|
|
# preferable to decoding large images just for diagnostics.
|
|
encoded = value.split(",", 1)[1][:131072]
|
|
with Image.open(io.BytesIO(base64.b64decode(encoded))) as image:
|
|
info.update(width=image.width, height=image.height)
|
|
except (ValueError, OSError):
|
|
pass
|
|
images.append(info)
|
|
return images
|
|
|
|
|
|
class RequestAttempt:
|
|
def __init__(self, engine, *, request_id, attempt, timeout, max_retries, metadata):
|
|
self.engine = engine
|
|
self.trace_id = uuid4()
|
|
self.step_id = getattr(engine, "current_step_id", None)
|
|
self.parent_id = CURRENT_TRACE_ID.get()
|
|
self.clock = time.monotonic()
|
|
self.last_publish = self.clock
|
|
self.tools: dict[int | str, dict] = {}
|
|
self.data = {
|
|
"images": [],
|
|
**metadata,
|
|
"request_id": request_id,
|
|
"attempt_id": str(self.trace_id),
|
|
"attempt": attempt,
|
|
"max_retries": max_retries,
|
|
"timeout_seconds": timeout,
|
|
"role": CURRENT_NODE_NAME.get() or metadata.get("role") or "model",
|
|
"started_at": time.time(),
|
|
"phase": "waiting_response",
|
|
"sequence": 0,
|
|
"usage": None,
|
|
"output_chunks": 0,
|
|
"tool_argument_chars": 0,
|
|
"tools": [],
|
|
"step_id": str(self.step_id) if self.step_id else None,
|
|
"parent_trace_id": str(self.parent_id) if self.parent_id else None,
|
|
}
|
|
self.publish()
|
|
|
|
def publish(self):
|
|
self.data["sequence"] += 1
|
|
self.data["updated_at"] = time.time()
|
|
self.data["elapsed_seconds"] = round(time.monotonic() - self.clock, 3)
|
|
self.last_publish = time.monotonic()
|
|
if not self.engine or not getattr(self.engine, "current_session_id", None):
|
|
return
|
|
try:
|
|
phase = self.data["phase"]
|
|
status = (
|
|
"success"
|
|
if phase == "completed"
|
|
else "failed"
|
|
if self.data.get("ended_at")
|
|
else "running"
|
|
)
|
|
self.engine.record_trace(
|
|
type="llm_diagnostic",
|
|
name="llm_request",
|
|
trace_id=self.trace_id,
|
|
step_id=self.step_id,
|
|
parent_trace_id=self.parent_id,
|
|
payload=deepcopy(self.data),
|
|
status=status,
|
|
duration=self.data["elapsed_seconds"],
|
|
)
|
|
except (OSError, RuntimeError, ValueError, TypeError, AttributeError):
|
|
logger.warning("Could not persist model request diagnostics", exc_info=True)
|
|
|
|
def headers(self, response):
|
|
self.data.update(
|
|
headers_at=time.time(),
|
|
http_status=response.status_code,
|
|
server_request_id=response.headers.get("x-request-id")
|
|
or response.headers.get("request-id"),
|
|
phase="waiting_output",
|
|
)
|
|
self.publish()
|
|
|
|
def chunk(self, chunk):
|
|
response_metadata = getattr(chunk, "response_metadata", {}) or {}
|
|
for field in ("status", "finish_reason"):
|
|
value = response_metadata.get(field)
|
|
if isinstance(value, str) and re.fullmatch(r"[\w.-]{1,64}", value):
|
|
self.data["response_" + field] = value
|
|
tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
|
|
# Responses exposes the same argument delta in content and tool_call_chunks.
|
|
# Count only the latter; retain lengths/names, never argument bodies.
|
|
for tool in tool_chunks:
|
|
index = tool.get("index")
|
|
key = index if index is not None else tool.get("id") or "unknown"
|
|
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0})
|
|
name = tool.get("name")
|
|
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
|
|
stats["name"] = name
|
|
arguments = tool.get("args")
|
|
if isinstance(arguments, str):
|
|
stats["argument_chars"] += len(arguments)
|
|
self.data["tool_argument_chars"] += len(arguments)
|
|
self.data["tools"] = list(self.tools.values())
|
|
content = getattr(chunk, "content", None)
|
|
substantive = isinstance(content, str) and bool(content)
|
|
if isinstance(content, list):
|
|
substantive = any(
|
|
isinstance(b, dict)
|
|
and (
|
|
b.get("text")
|
|
or b.get("thinking")
|
|
or b.get("summary")
|
|
or b.get("type") in {"tool_call", "function_call"}
|
|
)
|
|
for b in content
|
|
)
|
|
substantive = substantive or bool(tool_chunks) or bool(getattr(chunk, "tool_calls", None))
|
|
if not substantive:
|
|
return
|
|
now = time.time()
|
|
first = not self.data.get("first_output_at")
|
|
self.data.setdefault("first_output_at", now)
|
|
phase = "generating_tool_arguments" if tool_chunks else "receiving_output"
|
|
changed = self.data["phase"] != phase
|
|
self.data.update(last_output_at=now, phase=phase)
|
|
self.data["output_chunks"] += 1
|
|
if first or changed or time.monotonic() - self.last_publish >= 5:
|
|
self.publish()
|
|
|
|
def finish(self, phase, *, response=None, error=None, category=None):
|
|
if error is not None:
|
|
if isinstance(error, ToolArgumentOverflow):
|
|
self.data["stream_guard"] = error.details
|
|
self.data.update(
|
|
error=error_text(error, self.data["timeout_seconds"]),
|
|
error_type=type(error).__name__,
|
|
category=category,
|
|
failure_phase=self.data["phase"],
|
|
)
|
|
status = getattr(error, "status_code", None)
|
|
if isinstance(status, int):
|
|
self.data["http_status"] = status
|
|
if response is not None:
|
|
# The aggregate repeats all streaming deltas; do not count them twice.
|
|
if not self.data.get("first_output_at"):
|
|
self.chunk(response)
|
|
if (usage := usage_from_message(response)) is not None:
|
|
self.data["usage"] = usage
|
|
self.data.update(phase=phase, ended_at=time.time())
|
|
self.publish()
|
|
|
|
def retry(self, delay):
|
|
self.data.update(
|
|
phase="retry_wait", retry_at=time.time() + delay, retry_delay_seconds=delay
|
|
)
|
|
self.publish()
|
|
|
|
|
|
async def observe_request_headers(request):
|
|
"""Correlate a wire request and record only hashes of routing headers."""
|
|
attempt = ACTIVE_ATTEMPT.get()
|
|
if attempt is None:
|
|
return
|
|
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
|
|
routing = {}
|
|
for name in ("thread-id", "thread_id", "session-id", "session_id"):
|
|
value = request.headers.get(name)
|
|
if value is not None:
|
|
routing[name] = hashlib.sha256(value.encode()).hexdigest()[:16]
|
|
attempt.data.update(
|
|
request_sent_at=time.time(),
|
|
routing_header_fingerprints=routing,
|
|
client_request_id_matches=request.headers.get("X-Client-Request-Id")
|
|
== attempt.data["request_id"],
|
|
)
|
|
attempt.publish()
|
|
|
|
|
|
async def observe_response_headers(response):
|
|
"""httpx hook: only allowlisted headers, never body/auth/request headers."""
|
|
attempt = ACTIVE_ATTEMPT.get()
|
|
if attempt is not None:
|
|
attempt.headers(response)
|