feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
This commit is contained in:
1 parent
83bcfd54d5
commit
fac4ccb760
146 files changed
+5689
-2033
No files matched your search
@@ -17,11 +17,20 @@ from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExcee
|
||||
from artemis.llm.reliability import LLMExhaustedError
|
||||
from artemis.llm.usage import usage_from_message
|
||||
from artemis.llm.stream_guard import ToolArgumentOverflow
|
||||
from artemis.llm.response_guard import ResponseIntegrityError
|
||||
from artemis.runtime.cancellation import CancellationReason, cancellation_reason
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ACTIVE_ATTEMPT: ContextVar["RequestAttempt | None"] = ContextVar("llm_attempt", default=None)
|
||||
|
||||
|
||||
def _identifier(value):
|
||||
"""Keep bounded, printable correlation IDs; never arbitrary header text."""
|
||||
if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:/-]{1,256}", value):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def error_text(error: BaseException, timeout: float | None = None) -> str:
|
||||
if isinstance(error, FinalReviewBudgetExceeded):
|
||||
return str(error)
|
||||
@@ -80,7 +89,17 @@ def input_images(args: tuple, kwargs: dict) -> list[dict]:
|
||||
|
||||
|
||||
class RequestAttempt:
|
||||
def __init__(self, engine, *, request_id, attempt, timeout, max_retries, metadata):
|
||||
def __init__(
|
||||
self,
|
||||
engine,
|
||||
*,
|
||||
request_id,
|
||||
attempt,
|
||||
timeout,
|
||||
max_retries,
|
||||
metadata,
|
||||
previous_attempt_id=None,
|
||||
):
|
||||
self.engine = engine
|
||||
self.trace_id = uuid4()
|
||||
self.step_id = getattr(engine, "current_step_id", None)
|
||||
@@ -96,6 +115,7 @@ class RequestAttempt:
|
||||
**metadata,
|
||||
"request_id": request_id,
|
||||
"attempt_id": str(self.trace_id),
|
||||
"previous_attempt_id": previous_attempt_id,
|
||||
"attempt": attempt,
|
||||
"max_retries": max_retries,
|
||||
"timeout_seconds": timeout,
|
||||
@@ -147,18 +167,33 @@ class RequestAttempt:
|
||||
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"),
|
||||
server_request_id=_identifier(
|
||||
response.headers.get("x-request-id") or response.headers.get("request-id")
|
||||
),
|
||||
server_trace_id=_identifier(response.headers.get("x-trace-id")),
|
||||
phase="waiting_output",
|
||||
)
|
||||
traceparent = response.headers.get("traceparent", "")
|
||||
self.data.pop("traceparent", None)
|
||||
if re.fullmatch(r"[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}", traceparent):
|
||||
self.data["traceparent"] = traceparent
|
||||
self.publish()
|
||||
|
||||
def chunk(self, chunk):
|
||||
response_metadata = getattr(chunk, "response_metadata", {}) or {}
|
||||
def response_metadata(self, response):
|
||||
raw = response.get("raw") if isinstance(response, dict) else response
|
||||
metadata = getattr(raw, "response_metadata", {})
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
if response_id := _identifier(metadata.get("id")):
|
||||
self.data["response_id"] = response_id
|
||||
for field in ("status", "finish_reason"):
|
||||
value = response_metadata.get(field)
|
||||
value = metadata.get(field)
|
||||
if isinstance(value, str) and re.fullmatch(r"[\w.-]{1,64}", value):
|
||||
self.data["response_" + field] = value
|
||||
|
||||
def chunk(self, chunk):
|
||||
self.response_metadata(chunk)
|
||||
if (usage := usage_from_message(chunk)) is not None:
|
||||
self.data["usage"] = usage
|
||||
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.
|
||||
@@ -201,19 +236,27 @@ class RequestAttempt:
|
||||
self.publish()
|
||||
|
||||
def finish(self, phase, *, response=None, error=None, category=None):
|
||||
if category is not None:
|
||||
self.data["category"] = category
|
||||
if error is not None:
|
||||
if isinstance(error, ToolArgumentOverflow):
|
||||
self.data["stream_guard"] = error.details
|
||||
if isinstance(error, ResponseIntegrityError):
|
||||
self.data["response_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 request_id := _identifier(getattr(error, "request_id", None)):
|
||||
self.data["server_request_id"] = request_id
|
||||
if category == "timeout":
|
||||
self.data.setdefault("timeout_scope", "transport")
|
||||
if response is not None:
|
||||
self.response_metadata(response)
|
||||
# The aggregate repeats all streaming deltas; do not count them twice.
|
||||
if not self.data.get("first_output_at"):
|
||||
self.chunk(response)
|
||||
@@ -233,14 +276,23 @@ class RequestAttempt:
|
||||
self.publish()
|
||||
|
||||
def cancel(self, error: BaseException | None = None):
|
||||
if self.review and self.review.expired and not isinstance(error, KeyboardInterrupt):
|
||||
reason = cancellation_reason(error)
|
||||
if self.review and self.review.expired and reason is CancellationReason.UNKNOWN:
|
||||
self.data["timeout_scope"] = "final_review"
|
||||
self.finish(
|
||||
"failed",
|
||||
error=FinalReviewBudgetExceeded(self.review.total_seconds),
|
||||
category="final_review_budget_exhausted",
|
||||
)
|
||||
else:
|
||||
self.finish("cancelled", error=error, category="cancelled")
|
||||
self.data.update(cancel_reason=reason.value, cancel_phase=self.data["phase"])
|
||||
if reason is CancellationReason.OUTER_TIMEOUT:
|
||||
self.data["timeout_scope"] = "outer"
|
||||
else:
|
||||
self.data.pop("timeout_scope", None)
|
||||
# Preserve a retry's last failure, but never retain arbitrary text
|
||||
# from CancelledError.args (which may contain user guidance).
|
||||
self.finish("cancelled", category="cancelled")
|
||||
|
||||
|
||||
async def observe_request_headers(request):
|
||||
@@ -256,6 +308,7 @@ async def observe_request_headers(request):
|
||||
routing[name] = hashlib.sha256(value.encode()).hexdigest()[:16]
|
||||
attempt.data.update(
|
||||
request_sent_at=time.time(),
|
||||
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
|
||||
routing_header_fingerprints=routing,
|
||||
client_request_id_matches=request.headers.get("X-Client-Request-Id")
|
||||
== attempt.data["request_id"],
|
||||
|
||||
@@ -10,6 +10,7 @@ from uuid import uuid4
|
||||
|
||||
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
|
||||
from artemis.llm.reliability import LLMCallError, LLMExhaustedError
|
||||
from artemis.runtime.cancellation import cancellation_reason
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -146,6 +147,7 @@ class FinalReviewBudget:
|
||||
async with self.timer:
|
||||
result = await operation()
|
||||
except (KeyboardInterrupt, asyncio.CancelledError) as exc:
|
||||
self.data["cancel_reason"] = cancellation_reason(exc).value
|
||||
self.finish("cancelled", error=exc)
|
||||
raise
|
||||
except TimeoutError as exc:
|
||||
|
||||
@@ -36,6 +36,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from artemis.llm.stream_guard import ToolArgumentOverflow
|
||||
from artemis.llm.response_guard import ResponseIntegrityError
|
||||
|
||||
|
||||
class FailureCategory(StrEnum):
|
||||
@@ -95,7 +96,7 @@ def classify_failure(error: BaseException) -> Failure:
|
||||
"""Convert a provider/transport exception into explicit recovery decisions."""
|
||||
if isinstance(error, KeyboardInterrupt) or type(error).__name__ == "CancelledError":
|
||||
return Failure(FailureCategory.CANCELLED, False, False)
|
||||
if isinstance(error, ToolArgumentOverflow):
|
||||
if isinstance(error, (ToolArgumentOverflow, ResponseIntegrityError)):
|
||||
return Failure(FailureCategory.INVALID_OUTPUT, True, True)
|
||||
|
||||
code = extract_status_code(error)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Validate final Responses state and original tool JSON before consumption."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ResponseIntegrityError(ValueError):
|
||||
"""An unfinished response must not become an action or a formal verdict."""
|
||||
|
||||
def __init__(self, reason: str, *, status: str | None = None):
|
||||
self.details = {"reason": reason}
|
||||
if status in {"completed", "incomplete", "failed", "cancelled", "in_progress", "queued"}:
|
||||
self.details["status"] = status
|
||||
super().__init__(
|
||||
f"模型响应未完整有效结束({reason});已丢弃本次输出,未执行本次动作或采纳结论。"
|
||||
)
|
||||
|
||||
|
||||
def ensure_response_complete(response: Any) -> None:
|
||||
"""Check the raw message even when structured parsing already succeeded.
|
||||
|
||||
LangChain supplies ``model_provider=openai`` on Responses chunks, but may
|
||||
synthesize ``chunk_position=last`` at EOF or repair partial tool JSON. Neither
|
||||
is evidence of provider completion. Generic models without Responses metadata
|
||||
remain usable; their original tool argument strings still require strict JSON.
|
||||
"""
|
||||
raw = response.get("raw") if isinstance(response, dict) and "raw" in response else response
|
||||
metadata = getattr(raw, "response_metadata", None)
|
||||
metadata = metadata if isinstance(metadata, dict) else {}
|
||||
status = metadata.get("status")
|
||||
if metadata.get("model_provider") == "openai" or metadata.get("object") == "response":
|
||||
if status != "completed":
|
||||
raise ResponseIntegrityError(
|
||||
"missing_completion" if status is None else "response_not_completed", status=status
|
||||
)
|
||||
invalid_calls = getattr(raw, "invalid_tool_calls", None)
|
||||
if isinstance(invalid_calls, list) and invalid_calls:
|
||||
raise ResponseIntegrityError("invalid_tool_arguments")
|
||||
|
||||
chunks = getattr(raw, "tool_call_chunks", None)
|
||||
arguments = [tool.get("args") for tool in chunks] if isinstance(chunks, list) else []
|
||||
content = getattr(raw, "content", None)
|
||||
if isinstance(content, list):
|
||||
arguments.extend(
|
||||
block.get("arguments")
|
||||
for block in content
|
||||
if isinstance(block, dict) and block.get("type") == "function_call"
|
||||
)
|
||||
for value in arguments:
|
||||
if not isinstance(value, str):
|
||||
raise ResponseIntegrityError("invalid_tool_arguments")
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise ResponseIntegrityError("invalid_tool_arguments") from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ResponseIntegrityError("invalid_tool_arguments")
|
||||
Reference in new issue
Block a user