ReMe/reme/components/agent_wrapper/base_agent_wrapper.py
jinliyl c1a25e9ff4
feat(agent): add Codex agent wrapper and ReMe MCP bridge (#358)
* feat(agent): add Codex wrapper integration

* feat(agent): enhance agent wrapper functionality and add comprehensive testing

- Implement structured output schema normalization across all wrappers
- Add Claude Code system prompt mode support with append/replace options
- Introduce Codex agent wrapper with streaming, tool context isolation, and skill management
- Enhance skill linking with validation and conflict resolution
- Add approval event streaming support for Codex wrapper
- Implement output schema validation and normalize function
- Create dedicated test suites for Claude Code and Codex integration
- Update README documentation for Codex wrapper capabilities
- Refactor kwargs merging with proper schema handling
- Add tool context validation when resuming sessions
- Implement proper cleanup and session management for Codex wrapper

* test(cc-agent): add test coverage for structured output scenarios

- Add docstring for empty schema validation in build_options
- Document falsy structured output preservation behavior
- Add docstring for streaming wrapper schema rejection
- Include lambda function reference for wrapper factory consistency
- Add test documentation for live Codex wrapper contract exercise

* docs: revert README changes

* fix(agent): interrupt abandoned Codex turns
2026-07-17 13:39:18 +08:00

116 lines
4.7 KiB
Python

"""Base agent wrapper component."""
from abc import abstractmethod
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, TYPE_CHECKING
from pydantic import BaseModel
from ..base_component import BaseComponent
from ...enumeration import ChunkEnum, ComponentEnum
from ...schema import StreamChunk
if TYPE_CHECKING:
from ..job.base_job import BaseJob
class BaseAgentWrapper(BaseComponent):
"""Abstract base for agent wrapper components with swappable backends."""
component_type = ComponentEnum.AGENT_WRAPPER
def __init__(self, cwd: str | Path | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self._cwd = cwd
@property
def cwd(self) -> Path:
"""Working directory shared by the agent's shell and file tools.
Defaults to the project root (the workspace) — the same directory
Claude Code has always used. Override via the ``cwd`` init argument;
a relative value resolves against the workspace root.
"""
if not self._cwd:
return self.project_path
cwd = Path(self._cwd)
return cwd if cwd.is_absolute() else (self.workspace_path / cwd)
def set_system_prompt(self, prompt: str) -> "BaseAgentWrapper":
"""Set the agent's system prompt. Returns self for chaining."""
self.kwargs["system_prompt"] = prompt
return self
def add_job_tools(self, job_tools: list[str]) -> "BaseAgentWrapper":
"""Append job names as tools to the agent. Returns self for chaining."""
self.kwargs.setdefault("job_tools", []).extend(job_tools)
return self
def add_skills(self, skills: list[str] | str) -> "BaseAgentWrapper":
"""Set agent skill names. Returns self for chaining."""
self.kwargs["skills"] = skills
return self
@property
def project_path(self) -> Path:
"""Project root that contains shared assets such as skills."""
return self.workspace_path
@property
def project_skills_root(self) -> Path:
"""Project-level skills directory shared by agent backends."""
return self.project_path / "skills"
def set_output_schema(self, schema: dict | type[BaseModel]) -> "BaseAgentWrapper":
"""Set a JSON schema for structured output. Accepts dict or BaseModel class. Returns self for chaining."""
self.kwargs["output_schema"] = self._normalize_output_schema(schema)
return self
@staticmethod
def _normalize_output_schema(schema: Any) -> dict | None:
"""Return a JSON-serializable output schema shared by every backend."""
if isinstance(schema, type) and issubclass(schema, BaseModel):
return schema.model_json_schema()
if schema is None or isinstance(schema, dict):
return schema
raise TypeError("output_schema must be a JSON schema dict or BaseModel class")
def _resolve_job_tools(self, job_tools: list[str]) -> list["BaseJob"]:
"""Resolve job name strings to BaseJob instances via app_context."""
if not job_tools:
return []
if self.app_context is None:
raise RuntimeError("Cannot resolve job_tools without an app_context")
resolved: list["BaseJob"] = []
for name in job_tools:
if (job := self.app_context.jobs.get(name)) is None:
raise KeyError(f"Job '{name}' not found in app_context.jobs")
resolved.append(job)
return resolved
def _merged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Merge component defaults with call-time kwargs; call-time values win."""
merged = {**self.kwargs, **kwargs}
if "output_schema" in merged:
merged["output_schema"] = self._normalize_output_schema(merged["output_schema"])
return merged
def _merged_stream_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Merge stream options and reject unsupported structured output."""
merged = self._merged_kwargs(kwargs)
if merged.get("output_schema") is not None:
raise NotImplementedError("Structured output is not supported by reply_stream()")
return merged
@staticmethod
def _chunk(chunk_type: ChunkEnum = ChunkEnum.CONTENT, **kwargs: Any) -> StreamChunk:
"""Create a StreamChunk with a short backend-friendly call site."""
return StreamChunk(chunk_type=chunk_type, **kwargs)
@abstractmethod
async def reply(self, inputs: Any, **kwargs) -> dict:
"""Send inputs to the agent and return a dict with session_id and last_message."""
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream agent events as unified StreamChunk objects."""