eigent/backend/app/run_journal/model_capture.py

802 lines
28 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Capture CAMEL provider calls in the Desktop RunJournal.
The adapter deliberately wraps only CAMEL's public ``run``/``arun`` model
boundary. It does not parse or rewrite tool calls, and it does not modify
CAMEL's native ``camel_log`` settings or files. The two independent records
are intentionally retained so trajectory audits can compare their coverage.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import os
import threading
import uuid
from types import MethodType
from typing import Any
from app.permission_policy.models import redact_action_arguments
from app.run_context.context import get_current_run_context
from app.run_journal.models import ModelInvocationRecord
from app.run_journal.runtime import get_default_run_journal
from app.run_journal.store import SQLiteRunJournal
from app.workspace_config.models import canonical_digest
logger = logging.getLogger("model_invocation_capture")
_CAPTURE_INSTALLED = "_eigent_model_invocation_capture_installed"
_REDACTION_VERSION = "model-invocation-v1"
def _json_value(value: Any) -> Any:
if hasattr(value, "model_dump"):
try:
return value.model_dump(mode="json")
except TypeError:
return value.model_dump()
if hasattr(value, "dict"):
try:
return value.dict()
except Exception:
pass
if isinstance(value, dict):
return {str(key): _json_value(child) for key, child in value.items()}
if isinstance(value, (list, tuple)):
return [_json_value(child) for child in value]
if value is None or isinstance(value, (bool, int, float, str)):
return value
return repr(value)
def _response_document(value: Any) -> dict[str, Any]:
serialized = _json_value(value)
if isinstance(serialized, dict):
return serialized
return {"value": serialized}
def _request_document(
model_backend: Any,
messages: list[dict[str, Any]],
response_format: Any,
tools: list[dict[str, Any]] | None,
) -> dict[str, Any]:
config = _json_value(getattr(model_backend, "model_config_dict", {}) or {})
if not isinstance(config, dict):
config = {"value": config}
if tools is not None:
config["tools"] = _json_value(tools)
if response_format is not None:
if hasattr(response_format, "model_json_schema"):
try:
config["response_format"] = response_format.model_json_schema()
except Exception:
config["response_format"] = repr(response_format)
else:
config["response_format"] = repr(response_format)
return {
"messages": _json_value(messages),
"model_config_dict": config,
}
def _usage(response: dict[str, Any]) -> dict[str, int | None]:
raw = response.get("usage")
usage = raw if isinstance(raw, dict) else {}
prompt = usage.get("prompt_tokens", usage.get("input_tokens"))
completion = usage.get("completion_tokens", usage.get("output_tokens"))
cache_read = usage.get("cache_read_input_tokens")
cache_write = usage.get("cache_creation_input_tokens")
details = usage.get("prompt_tokens_details")
if isinstance(details, dict):
cache_read = cache_read or details.get("cached_tokens")
def non_negative_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
return {
"prompt_tokens": non_negative_int(prompt),
"completion_tokens": non_negative_int(completion),
"cache_read_tokens": non_negative_int(cache_read),
"cache_write_tokens": non_negative_int(cache_write),
}
def _finish_reason(response: dict[str, Any]) -> str | None:
direct = response.get("finish_reason")
if direct is not None:
return str(direct)
choices = response.get("choices")
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
value = choices[0].get("finish_reason")
return str(value) if value is not None else None
return None
def _error_code(exc: BaseException) -> str:
for key in ("code", "status_code", "type"):
value = getattr(exc, key, None)
if value is not None:
return str(value)
return type(exc).__name__
def _transport(model_backend: Any) -> str:
# CAMEL stores api_mode as an initializer-owned private attribute rather
# than in model_config_dict. Prefer that authoritative runtime value so a
# Responses request is never mislabeled as Chat Completions in SQLite.
runtime_mode = getattr(model_backend, "_api_mode", None)
if runtime_mode:
return str(runtime_mode)
config = getattr(model_backend, "model_config_dict", {}) or {}
if isinstance(config, dict):
explicit = config.get("api_mode") or config.get("transport")
if explicit:
return str(explicit)
return "chat_completions"
def _thinking_effort(model_backend: Any) -> str | None:
config = getattr(model_backend, "model_config_dict", {}) or {}
if not isinstance(config, dict):
return None
reasoning = config.get("reasoning")
response_effort = (
reasoning.get("effort") if isinstance(reasoning, dict) else None
)
value = (
config.get("reasoning_effort")
or config.get("thinking_effort")
or response_effort
)
return str(value) if value is not None else None
class _StreamAccumulator:
def __init__(self) -> None:
self.response_id = ""
self.model = ""
self.content: list[str] = []
self.reasoning_content: list[str] = []
self.finish_reason: str | None = None
self.usage: dict[str, Any] | None = None
self.tool_calls: dict[int, dict[str, Any]] = {}
def add(self, chunk: Any) -> bool:
payload = _response_document(chunk)
event_type = payload.get("type")
if event_type == "content.delta":
delta = payload.get("delta")
if isinstance(delta, str) and delta:
self.content.append(delta)
return True
return False
self.response_id = self.response_id or str(payload.get("id") or "")
self.model = self.model or str(payload.get("model") or "")
usage = payload.get("usage")
if isinstance(usage, dict):
self.usage = usage
choices = payload.get("choices")
if not isinstance(choices, list):
return False
observed_output = False
for choice in choices:
if not isinstance(choice, dict):
continue
if choice.get("finish_reason") is not None:
self.finish_reason = str(choice["finish_reason"])
delta = choice.get("delta")
if not isinstance(delta, dict):
continue
content = delta.get("content")
if isinstance(content, str):
self.content.append(content)
observed_output = observed_output or bool(content)
reasoning = delta.get("reasoning_content")
if isinstance(reasoning, str):
self.reasoning_content.append(reasoning)
observed_output = observed_output or bool(reasoning)
calls = delta.get("tool_calls")
if not isinstance(calls, list):
continue
for position, call in enumerate(calls):
if not isinstance(call, dict):
continue
index = int(call.get("index", position))
aggregate = self.tool_calls.setdefault(
index,
{
"id": "",
"type": call.get("type", "function"),
"function": {"name": "", "arguments": ""},
},
)
if call.get("id"):
aggregate["id"] = str(call["id"])
observed_output = True
function = call.get("function")
if isinstance(function, dict):
if function.get("name"):
aggregate["function"]["name"] += str(function["name"])
observed_output = True
if function.get("arguments"):
aggregate["function"]["arguments"] += str(
function["arguments"]
)
observed_output = True
return observed_output
def document(self) -> dict[str, Any]:
return {
"id": self.response_id,
"model": self.model,
"content": "".join(self.content),
"reasoning_content": "".join(self.reasoning_content) or None,
"tool_calls": [
self.tool_calls[index] for index in sorted(self.tool_calls)
],
"finish_reason": self.finish_reason,
"usage": self.usage,
"streaming": True,
}
class _CaptureSession:
def __init__(
self,
*,
journal: SQLiteRunJournal,
record: ModelInvocationRecord,
) -> None:
self.journal = journal
self.record = record
self._closed = False
self._first_token = False
self._lock = threading.Lock()
@property
def closed(self) -> bool:
with self._lock:
return self._closed
def first_token(self) -> None:
with self._lock:
if self._closed or self._first_token:
return
self._first_token = True
try:
self.journal.mark_model_invocation_first_token(
self.record.invocation_id
)
except Exception:
logger.exception("Failed to persist model first-token marker")
async def afirst_token(self) -> None:
await asyncio.to_thread(self.first_token)
def complete(self, response: dict[str, Any]) -> None:
with self._lock:
if self._closed:
return
self._closed = True
usage = _usage(response)
try:
self.journal.finish_model_invocation(
self.record.invocation_id,
status="completed",
response=response,
finish_reason=_finish_reason(response),
**usage,
)
except Exception:
logger.exception("Failed to complete durable model invocation")
async def acomplete(self, response: dict[str, Any]) -> None:
await asyncio.to_thread(self.complete, response)
def fail(
self, exc: BaseException, *, outcome_unknown: bool = False
) -> None:
with self._lock:
if self._closed:
return
self._closed = True
status = "outcome_unknown" if outcome_unknown else "failed"
try:
self.journal.finish_model_invocation(
self.record.invocation_id,
status=status,
error_code=_error_code(exc),
error_message=str(exc),
)
except Exception:
logger.exception("Failed to close durable model invocation")
async def afail(
self, exc: BaseException, *, outcome_unknown: bool = False
) -> None:
await asyncio.to_thread(
self.fail, exc, outcome_unknown=outcome_unknown
)
class _RecordedSyncStream:
def __init__(self, stream: Any, session: _CaptureSession) -> None:
self._stream = stream
self._session = session
self._accumulator = _StreamAccumulator()
def __iter__(self) -> _RecordedSyncStream:
return self
def __next__(self) -> Any:
try:
chunk = next(self._stream)
except StopIteration:
self._complete_from_stream()
raise
except BaseException as exc:
self._session.fail(exc, outcome_unknown=True)
raise
try:
if self._accumulator.add(chunk):
self._session.first_token()
except Exception as exc:
# A capture projection failure must not corrupt a provider stream
# that CAMEL can otherwise consume.
self._session.fail(exc, outcome_unknown=True)
return chunk
def __enter__(self) -> _RecordedSyncStream:
enter = getattr(self._stream, "__enter__", None)
if enter is not None:
try:
entered = enter()
except BaseException as exc:
self._session.fail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if entered is not self._stream and hasattr(entered, "__next__"):
self._stream = entered
return self
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
if exc is not None:
self._session.fail(exc, outcome_unknown=True)
elif not self._session.closed:
self._session.fail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
close = getattr(self._stream, "__exit__", None)
return bool(close(exc_type, exc, traceback)) if close else False
def __getattr__(self, name: str) -> Any:
return getattr(self._stream, name)
def _complete_from_stream(self) -> None:
try:
finalizer = getattr(self._stream, "get_final_completion", None)
response = (
_response_document(finalizer())
if callable(finalizer)
else self._accumulator.document()
)
except Exception as exc:
self._session.fail(exc, outcome_unknown=True)
return
self._session.complete(response)
def get_final_completion(self) -> Any:
response = self._stream.get_final_completion()
if not self._session.closed:
try:
self._session.complete(_response_document(response))
except Exception as exc:
self._session.fail(exc, outcome_unknown=True)
return response
def until_done(self) -> Any:
result = self._stream.until_done()
if not self._session.closed:
self._complete_from_stream()
return result
def close(self) -> Any:
if not self._session.closed:
self._session.fail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
close = getattr(self._stream, "close", None)
return close() if callable(close) else None
class _RecordedSyncStreamManager:
"""Preserve sync provider context-manager semantics while recording."""
def __init__(self, manager: Any, session: _CaptureSession) -> None:
self._manager = manager
self._session = session
self._stream: _RecordedSyncStream | None = None
def __enter__(self) -> _RecordedSyncStream:
try:
entered = self._manager.__enter__()
except BaseException as exc:
self._session.fail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if not hasattr(entered, "__iter__"):
error = TypeError(
"model stream context manager did not yield a sync stream"
)
self._session.fail(error, outcome_unknown=True)
raise error
self._stream = _RecordedSyncStream(entered, self._session)
return self._stream
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
if exc is not None:
self._session.fail(exc, outcome_unknown=True)
elif not self._session.closed:
self._session.fail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
return bool(self._manager.__exit__(exc_type, exc, traceback))
def __getattr__(self, name: str) -> Any:
return getattr(self._manager, name)
class _RecordedAsyncStream:
def __init__(self, stream: Any, session: _CaptureSession) -> None:
self._stream = stream
self._session = session
self._accumulator = _StreamAccumulator()
def __aiter__(self) -> _RecordedAsyncStream:
return self
async def __anext__(self) -> Any:
try:
chunk = await self._stream.__anext__()
except StopAsyncIteration:
await self._complete_from_stream()
raise
except BaseException as exc:
await self._session.afail(exc, outcome_unknown=True)
raise
try:
if self._accumulator.add(chunk):
await self._session.afirst_token()
except Exception as exc:
await self._session.afail(exc, outcome_unknown=True)
return chunk
async def __aenter__(self) -> _RecordedAsyncStream:
enter = getattr(self._stream, "__aenter__", None)
if enter is not None:
try:
entered = await enter()
except BaseException as exc:
await self._session.afail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if entered is not self._stream and hasattr(entered, "__anext__"):
self._stream = entered
return self
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
if exc is not None:
await self._session.afail(exc, outcome_unknown=True)
elif not self._session.closed:
await self._session.afail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
close = getattr(self._stream, "__aexit__", None)
return bool(await close(exc_type, exc, traceback)) if close else False
def __getattr__(self, name: str) -> Any:
return getattr(self._stream, name)
async def _complete_from_stream(self) -> None:
try:
finalizer = getattr(self._stream, "get_final_completion", None)
if callable(finalizer):
response = finalizer()
if inspect.isawaitable(response):
response = await response
document = _response_document(response)
else:
document = self._accumulator.document()
except Exception as exc:
await self._session.afail(exc, outcome_unknown=True)
return
await self._session.acomplete(document)
async def get_final_completion(self) -> Any:
response = self._stream.get_final_completion()
if inspect.isawaitable(response):
response = await response
if not self._session.closed:
try:
await self._session.acomplete(_response_document(response))
except Exception as exc:
await self._session.afail(exc, outcome_unknown=True)
return response
async def until_done(self) -> Any:
result = self._stream.until_done()
if inspect.isawaitable(result):
result = await result
if not self._session.closed:
await self._complete_from_stream()
return result
async def close(self) -> Any:
if not self._session.closed:
await self._session.afail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
close = getattr(self._stream, "close", None)
if close is None:
close = getattr(self._stream, "aclose", None)
result = close() if callable(close) else None
return await result if inspect.isawaitable(result) else result
async def aclose(self) -> Any:
return await self.close()
class _RecordedAsyncStreamManager:
"""Preserve provider context-manager semantics while recording output."""
def __init__(self, manager: Any, session: _CaptureSession) -> None:
self._manager = manager
self._session = session
self._stream: _RecordedAsyncStream | None = None
async def __aenter__(self) -> _RecordedAsyncStream:
try:
entered = await self._manager.__aenter__()
except BaseException as exc:
await self._session.afail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if not hasattr(entered, "__aiter__"):
error = TypeError(
"model stream context manager did not yield an async stream"
)
await self._session.afail(error, outcome_unknown=True)
raise error
self._stream = _RecordedAsyncStream(entered, self._session)
return self._stream
async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool:
if exc is not None:
await self._session.afail(exc, outcome_unknown=True)
elif not self._session.closed:
await self._session.afail(
RuntimeError("model stream closed before exhaustion"),
outcome_unknown=True,
)
return bool(await self._manager.__aexit__(exc_type, exc, traceback))
def __getattr__(self, name: str) -> Any:
return getattr(self._manager, name)
def _exception_outcome_unknown(exc: BaseException) -> bool:
"""Return whether dispatch may have reached the provider."""
return isinstance(exc, (asyncio.CancelledError, TimeoutError)) or (
"timeout" in type(exc).__name__.lower()
)
def _start_capture(
*,
journal: SQLiteRunJournal,
model_backend: Any,
agent_id: str,
provider: str,
model_name: str,
messages: list[dict[str, Any]],
call_args: tuple[Any, ...],
call_kwargs: dict[str, Any],
) -> _CaptureSession | None:
context = get_current_run_context()
if context is None:
return None
response_format = call_kwargs.get(
"response_format", call_args[0] if call_args else None
)
tools = call_kwargs.get(
"tools", call_args[1] if len(call_args) > 1 else None
)
try:
request = _request_document(
model_backend, messages, response_format, tools
)
safe_request = redact_action_arguments({"request": request})["request"]
assert isinstance(safe_request, dict)
request_digest = canonical_digest(safe_request)
logical_call_id = canonical_digest(
{
"run_id": context.run_id,
"agent_id": agent_id,
"request_digest": request_digest,
}
)
if journal.get_run(context.run_id) is None:
logger.warning(
"Skipping model capture because Run %s is not admitted",
context.run_id,
)
return None
record = journal.start_model_invocation(
invocation_id=f"modelinv_{uuid.uuid4().hex}",
run_id=context.run_id,
attempt_id=context.attempt_id,
agent_id=agent_id,
logical_call_id=logical_call_id,
provider=provider,
model=model_name,
transport=_transport(model_backend),
thinking_effort=_thinking_effort(model_backend),
request=request,
redaction_version=_REDACTION_VERSION,
)
except Exception:
logger.exception("Failed to start durable model invocation")
if os.environ.get("EIGENT_MODEL_CAPTURE_REQUIRED", "").lower() in {
"1",
"true",
"yes",
"on",
}:
raise
return None
return _CaptureSession(journal=journal, record=record)
def instrument_model_backend(
model_backend: Any,
*,
agent_id: str,
provider: str,
model_name: str,
journal: SQLiteRunJournal | None = None,
) -> Any:
"""Install one idempotent capture adapter on a CAMEL model instance."""
# TODO(camel): Replace this public run/arun adapter when CAMEL exposes a
# transport-attempt hook with pre-dispatch, terminal/stream, provider
# request-id, and SDK retry-index callbacks. Without that upstream hook,
# one CAMEL model call is durable here but hidden HTTP retries cannot be
# represented as separate ModelInvocation rows.
if getattr(model_backend, _CAPTURE_INSTALLED, False):
return model_backend
durable_journal = journal or get_default_run_journal()
original_run = model_backend.run
original_arun = model_backend.arun
def captured_run(
self: Any,
messages: list[dict[str, Any]],
*args: Any,
**kwargs: Any,
) -> Any:
session = _start_capture(
journal=durable_journal,
model_backend=self,
agent_id=agent_id,
provider=provider,
model_name=model_name,
messages=messages,
call_args=args,
call_kwargs=kwargs,
)
try:
response = original_run(messages, *args, **kwargs)
except BaseException as exc:
if session is not None:
session.fail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if session is None:
return response
if inspect.isgenerator(response) or (
hasattr(response, "__next__")
and hasattr(response, "__iter__")
and not hasattr(response, "choices")
):
return _RecordedSyncStream(response, session)
if hasattr(response, "__enter__"):
return _RecordedSyncStreamManager(response, session)
try:
document = _response_document(response)
except Exception as exc:
session.fail(exc, outcome_unknown=True)
return response
session.complete(document)
return response
async def captured_arun(
self: Any,
messages: list[dict[str, Any]],
*args: Any,
**kwargs: Any,
) -> Any:
session = await asyncio.to_thread(
_start_capture,
journal=durable_journal,
model_backend=self,
agent_id=agent_id,
provider=provider,
model_name=model_name,
messages=messages,
call_args=args,
call_kwargs=kwargs,
)
try:
response = await original_arun(messages, *args, **kwargs)
except BaseException as exc:
if session is not None:
await session.afail(
exc, outcome_unknown=_exception_outcome_unknown(exc)
)
raise
if session is None:
return response
if hasattr(response, "__aiter__"):
return _RecordedAsyncStream(response, session)
if hasattr(response, "__aenter__"):
return _RecordedAsyncStreamManager(response, session)
try:
document = _response_document(response)
except Exception as exc:
await session.afail(exc, outcome_unknown=True)
return response
await session.acomplete(document)
return response
model_backend.run = MethodType(captured_run, model_backend)
model_backend.arun = MethodType(captured_arun, model_backend)
setattr(model_backend, _CAPTURE_INSTALLED, True)
return model_backend
__all__ = ["instrument_model_backend"]