feat: add official Codex skill and harden task execution

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.
This commit is contained in:
chenmingxuan committed 2026-10-18 14:18:37 +08:00
1 parent 2328a4df0c
commit e1ea099592
128 files changed
+4944 -700

No files matched your search

@@ -320,6 +320,7 @@ class SessionRepository:
return 0
llm_calls = prompt = completion = total = cached = 0
by_node_model: dict[tuple[str, str], dict] = {}
operator_context: int | None = None
operator_context_at: float | None = None
with db_session(self.db_path) as conn:
@@ -348,6 +349,27 @@ class SessionRepository:
total += t
cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown")
group = by_node_model.setdefault(
(node, model_name),
{
"node": node,
"model": model_name,
"llm_calls": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"cached_tokens": 0,
},
)
group["llm_calls"] += 1
group["prompt_tokens"] += p
group["completion_tokens"] += c
group["cached_tokens"] += _int(payload.get("cached_tokens"))
group["cache_hit_rate"] = (
group["cached_tokens"] / group["prompt_tokens"]
if group["prompt_tokens"]
else None
)
if node in OPERATOR_NODE_NAMES:
operator_context = _int(payload.get("context_base_tokens")) or p
operator_context_at = row["timestamp"]
@@ -375,6 +397,7 @@ class SessionRepository:
"completion_tokens": completion,
"total_tokens": total,
"cached_tokens": cached,
"by_node_model": list(by_node_model.values()),
"operator_context_tokens": operator_context,
"operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS,
"operator_context_updated_at": operator_context_at,
+17
View File
@@ -23,6 +23,23 @@ from apps.admin_console.services.exploration_service import exploration_service
router = APIRouter(prefix="/api/explorations", tags=["exploration"])
@router.get("/{exploration_id}/deletion-preview")
def preview_deletion(exploration_id: str):
from apps.admin_console.services.task_deletion import exploration_deletion_preview
with domain_errors():
return exploration_deletion_preview(exploration_id)
@router.delete("/{exploration_id}")
def delete_conversation(exploration_id: str):
from apps.admin_console.services.exploration_deletion import delete_exploration
with domain_errors():
deleted = delete_exploration(service.repository, exploration_id)
return {"status": "success", "deleted_session_ids": deleted}
@contextmanager
def domain_errors():
try:
+33 -1
View File
@@ -16,7 +16,7 @@ import asyncio
import json
import time
from uuid import UUID
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from artemis.config import DB_PATH, TRACES_PATH
@@ -36,6 +36,23 @@ from apps.admin_console.services.exploration_listing import exploration_metadata
router = APIRouter(tags=["sessions"])
@router.get("/api/sessions/{session_id}/inspection")
def inspect_session(
session_id: str,
view: str = "report",
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
):
from apps.admin_console.services.task_inspection import inspect_task
try:
return inspect_task(session_id, view, offset=offset, limit=limit)
except LookupError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.get("/api/sessions/{session_id}/request-diagnostics")
def get_request_diagnostics(session_id: str, download: bool = False):
from apps.admin_console.services.request_diagnostics import request_diagnostics
@@ -281,6 +298,18 @@ async def cleanup_history_endpoint():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/sessions/{session_id}/deletion-preview")
def preview_session_deletion(session_id: str):
from apps.admin_console.services.task_deletion import deletion_preview
try:
return deletion_preview(session_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
@router.post("/api/sessions/{session_id}/delete")
async def delete_session_endpoint(session_id: str):
from artemis.exploration.store import ExplorationConflict, explorations
@@ -295,6 +324,7 @@ async def delete_session_endpoint(session_id: str):
deleted = delete_exploration(explorations, run["exploration_id"])
return {"status": "success", "deleted_session_ids": deleted}
preview_session_deletion(session_id)
storage = StorageManager(DB_PATH, TRACES_PATH)
storage.delete_session(UUID(session_id))
from apps.admin_console.services.automation_store import store
@@ -307,6 +337,8 @@ async def delete_session_endpoint(session_id: str):
}
except ExplorationConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except HTTPException:
raise
except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
+4
View File
@@ -105,6 +105,10 @@ async def _lifespan(_app: "FastAPI"):
app.state.service_registration = registration
configure_local_daemon(state.port)
try:
from artemis.integrations.codex_skill import sync_on_startup
from artemis.config.paths import ROOT_DIR
await asyncio.to_thread(sync_on_startup, ROOT_DIR)
shared_tool_executor.start()
async with shared_mcp.session_manager.run():
await on_startup()
@@ -2,6 +2,7 @@
from artemis.config.agent import load_agent_config
from artemis.config.exploration import ExplorationConfig
from artemis.exploration.case_prompt import require_resolved_case
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
from artemis.exploration.demonstrations import Demonstrations
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
@@ -10,8 +11,7 @@ from apps.admin_console.services.task_import import TaskOptions
def exploration_case_prompt(case: dict) -> str:
prefix = f"前置条件:\n{case['preconditions']}\n\n" if case.get("preconditions") else ""
return prefix + case["goal"]
return require_resolved_case(case)
class ExplorationService:
@@ -7,7 +7,9 @@ import time
from apps.admin_console.database.connection import db_session
def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = None) -> dict:
def request_diagnostics(
session_id: str, *, db_path=None, limit: int | None = None, offset: int = 0
) -> dict:
with db_session(db_path) as conn:
session = conn.execute(
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
@@ -17,9 +19,13 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
"ORDER BY timestamp DESC"
)
params: tuple = (session_id,)
total = conn.execute(
"SELECT count(*) FROM traces WHERE session_id=? AND type='llm_diagnostic'",
params,
).fetchone()[0]
if limit is not None:
sql += " LIMIT ?"
params += (limit,)
sql += " LIMIT ? OFFSET ?"
params += (limit, offset)
rows = conn.execute(sql, params).fetchall()
requests = []
seen = set()
@@ -34,6 +40,7 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
request_id = value.get("request_id")
value["active"] = (
running
and offset == 0
and request_id not in seen
and value.get("phase")
in {
@@ -64,6 +71,9 @@ def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = No
"session_status": session["status"] if session else "unknown",
"collected_at": time.time(),
"requests": requests,
"total": total,
"offset": offset,
"next_offset": offset + limit if limit is not None and offset + limit < total else None,
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
}
@@ -0,0 +1,40 @@
"""Read the impact of deleting a task without deleting or stopping anything."""
from uuid import UUID
from artemis.exploration.store import ExplorationConflict, explorations
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services.automation_store import store
from apps.admin_console.services.exploration_deletion import _check_runs
def deletion_preview(session_id: str) -> dict:
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
return exploration_deletion_preview(run["exploration_id"])
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise KeyError("任务不存在")
_check_runs([{"id": session_id}])
job = store.job(session_id) or {}
if job.get("status") in {"pending", "queued", "running", "task_paused", "resuming"}:
raise ExplorationConflict("任务仍在执行或排队,请先停止并等待结束")
return {
"session_ids": [session_id],
"preserved_session_ids": [],
"removes": ["任务历史", "报告", "截图、录像及附件"],
}
def exploration_deletion_preview(exploration_id: str) -> dict:
session = explorations.get(exploration_id)
runs = explorations.runs(exploration_id)
_check_runs(runs)
if session.get("active_run_id") or session.get("active_demonstration_id"):
raise ExplorationConflict("探索尚有活动任务或示范,请先停止并等待结束")
return {
"exploration_id": exploration_id,
"session_ids": [run["id"] for run in runs if run["kind"] == "exploration"],
"preserved_session_ids": [run["id"] for run in runs if run["kind"] != "exploration"],
"removes": ["探索对话", "用例版本", "探索执行历史及证据"],
}
@@ -0,0 +1,147 @@
"""Bounded, read-only task diagnostics shared by MCP and HTTP clients."""
import os
import re
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.services import evidence_service, report_export
from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services.request_diagnostics import request_diagnostics
_SECRET = re.compile(
r"(?i)^(api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|client_secret|token_key)$"
)
def public_value(value, *, max_chars: int = 60000):
"""Remove common credential forms and bound text without inventing missing evidence."""
remaining = max_chars
remaining_nodes = 2000
truncated = False
secrets = [
v
for k, v in os.environ.items()
if len(v) >= 8 and re.search(r"(?i)(api_?key|password|secret|token)$", k)
]
def clean(item, depth=0):
nonlocal remaining, remaining_nodes, truncated
remaining_nodes -= 1
if depth > 12:
truncated = True
return "[嵌套内容已截断]"
if isinstance(item, dict):
result = {}
for k, v in item.items():
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
key = clean(str(k), depth + 1)
result[key] = "[REDACTED]" if _SECRET.fullmatch(str(k)) else clean(v, depth + 1)
return result
if isinstance(item, list):
result = []
for v in item:
if remaining_nodes <= 0 or len(result) >= 100:
truncated = True
break
result.append(clean(v, depth + 1))
return result
if not isinstance(item, str):
return item
for secret in secrets:
item = item.replace(secret, "[REDACTED]")
item = re.sub(r"(?i)\bBearer\s+[^\s\"',;]+", "Bearer [REDACTED]", item)
item = re.sub(r"\bsk-[A-Za-z0-9_-]+", "[REDACTED]", item)
item = re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[REDACTED]@", item)
item = re.sub(
r"""(?ix)((?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|password|secret|token_key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s&,;]+)""",
r"\1[REDACTED]",
item,
)
allowance = min(8000, remaining)
if len(item) > allowance:
truncated = True
item = item[:allowance] + "…[已截断;按步骤或关键词缩小范围]"
remaining = max(0, remaining - min(len(item), allowance))
return item
result = clean(value)
return {**result, "text_truncated": truncated, "content_truncated": truncated}
def page(items: list, offset: int, limit: int) -> dict:
return {
"items": items[offset : offset + limit],
"total": len(items),
"offset": offset,
"limit": limit,
"next_offset": offset + limit if offset + limit < len(items) else None,
}
def inspect_task(session_id: str, view: str, *, offset: int = 0, limit: int = 20) -> dict:
identifier(session_id)
if offset < 0 or not 1 <= limit <= 100:
raise ValueError("offset 必须非负,limit 必须在 1–100 之间")
if not session_repo.get_session_by_id(session_id) and not store.job(session_id):
raise LookupError("任务记录不存在")
if view == "usage":
result = session_repo.get_session_usage(session_id)
result["cache_hit_rate"] = (
result["cached_tokens"] / result["prompt_tokens"] if result["prompt_tokens"] else None
)
result["note"] = (
"缓存率=已记录 cached_tokens / prompt_tokens;无用量不等于零消耗,未上报的请求无法补推。"
)
elif view == "requests":
result = request_diagnostics(session_id, limit=limit, offset=offset)
elif view == "logs":
root = evidence_service.task_root(session_id)
result = {"logs": []}
for name in ("stdout.log", "stderr.log"):
path = evidence_service.evidence_file(root, name)
if path is None:
result["logs"].append({"name": name, "available": False})
continue
with path.open("rb") as stream:
size = path.stat().st_size
start = max(0, size - 128 * 1024)
stream.seek(start)
raw = stream.read(128 * 1024)
lines = raw.decode("utf-8", errors="replace").splitlines()
if start and lines:
lines = lines[1:]
# Offset counts backward from the newest line within the bounded tail.
end = max(0, len(lines) - offset)
result["logs"].append(
{
"name": name,
"available": True,
"bytes": size,
"lines": lines[max(0, end - limit) : end],
"offset": offset,
"next_offset": offset + limit if end > limit else None,
"older_bytes_omitted": start,
"order": "oldest_to_newest",
}
)
elif view in {"report", "evidence"}:
report = report_export.session_report(session_id)
if view == "report":
result = {**report, "steps": page(report.get("steps", []), offset, limit)}
else:
index = evidence_service.export_index(
session_id, report, report_export.session_videos(report)
)
result = {
**index,
**{
key: page(index.get(key, []), offset, limit)
for key in ("steps", "calls", "recordings", "android_videos", "files")
},
}
else:
raise ValueError("view 需为 report / usage / requests / logs / evidence")
return public_value({"session_id": session_id, **result})