Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/api/routers/coding_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ async def get_agent_options(
return {
"models": models,
"default_model": claude_code_runner.default_model_id(policy=policy),
"runtime_engines": [
{"id": "claude", "label": "Claude Agent SDK"},
{"id": "codex", "label": "Codex SDK"},
],
"default_runtime_engine": "claude",
"skills": skills,
"mcp": databases,
}
Expand Down Expand Up @@ -418,6 +423,7 @@ async def create_agent_run(workspace_id: str, body: ClaudeAgentRunCreate, identi
profile = workspace_profile.get_profile(workspace)
run_skills = list(body.skills or []) or list(profile.get("default_skills") or [])
run_model = claude_code_runner.resolve_run_model(body.model or profile.get("default_model") or "")
runtime_engine = str(profile.get("runtime_engine") or "claude").strip().lower()

run = claude_agent_runs.create_run(
workspace_id=workspace_id,
Expand All @@ -431,6 +437,7 @@ async def create_agent_run(workspace_id: str, body: ClaudeAgentRunCreate, identi
"selected_skills": run_skills,
"previous_run_id": body.previous_run_id,
"attachments": attachment_paths,
"runtime_engine": runtime_engine,
},
)
claude_code_runner.schedule_run(run["run_id"])
Expand Down
333 changes: 330 additions & 3 deletions backend/api/routers/gateway.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class WorkspaceUpdate(BaseModel):

class WorkspaceProfileUpdate(BaseModel):
agent_type: str = Field(default="", max_length=64)
runtime_engine: Literal["claude", "codex"] = "claude"
soul: str = Field(default="", max_length=8000)
paradigm: str = Field(default="", max_length=8000)
standards: str = Field(default="", max_length=8000)
Expand Down
43 changes: 43 additions & 0 deletions backend/infra/gateway_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,49 @@ def build_upstream_call(
return target, headers, req


def build_responses_upstream_call(
body: dict[str, Any],
effective_model: str,
) -> tuple[str, dict[str, str], dict[str, Any]]:
"""Return (url, headers, request_body) for one upstream /responses call."""
req = copy.deepcopy(body)
req["model"] = effective_model
metadata = req.get("metadata") if isinstance(req.get("metadata"), dict) else {}
req["metadata"] = {**metadata, "evotown_effective_model": effective_model}

managed = gateway_models_store.get_by_model_name(effective_model)
if managed:
api_base = managed.get("_api_base") or ""
if not api_base.startswith("http"):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Upstream model '{effective_model}' has invalid api_base.",
)
req["model"] = managed.get("_litellm_model") or effective_model
req["metadata"]["evotown_upstream_model_id"] = managed.get("model_id", "")
req["metadata"]["evotown_upstream_mode"] = "managed"
target = f"{api_base.rstrip('/')}/responses"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {managed.get('_api_key', '')}",
}
return target, headers, req

litellm_base = litellm_base_url()
if not litellm_base:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
f"Model '{effective_model}' is not registered in Evotown and LITELLM_BASE_URL is not configured. "
"Add the model under Gateway → 上游模型 in the console."
),
)
req["metadata"]["evotown_upstream_mode"] = "litellm"
target = f"{litellm_base}/responses"
headers = {"Content-Type": "application/json", **litellm_auth_header()}
return target, headers, req


def build_anthropic_upstream_call(
body: dict[str, Any],
effective_model: str,
Expand Down
16 changes: 15 additions & 1 deletion backend/infra/workspace_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any

from infra import workspaces
from services.runtime_engine import DEFAULT_RUNTIME_ENGINE, normalize_runtime_engine

PROFILE_RELATIVE = ".evotown/profile.json"
PROFILE_MD_RELATIVE = ".evotown/AGENT_PROFILE.md"
Expand All @@ -15,6 +16,7 @@

DEFAULT_PROFILE: dict[str, Any] = {
"agent_type": "",
"runtime_engine": DEFAULT_RUNTIME_ENGINE,
"soul": "",
"paradigm": "",
"standards": "",
Expand All @@ -39,6 +41,7 @@ def get_profile(workspace: dict[str, Any]) -> dict[str, Any]:
if not isinstance(raw, dict):
return {**DEFAULT_PROFILE, "updated_at": None}
merged = {**DEFAULT_PROFILE, **raw}
merged["runtime_engine"] = normalize_runtime_engine(merged.get("runtime_engine"))
merged["default_skills"] = _normalize_id_list(merged.get("default_skills"))
merged["default_mcp"] = _normalize_id_list(merged.get("default_mcp"))
return merged
Expand Down Expand Up @@ -73,6 +76,9 @@ def save_profile(workspace: dict[str, Any], payload: dict[str, Any]) -> dict[str
payload.get("agent_type", current.get("agent_type", "")),
max_chars=AGENT_TYPE_MAX,
),
"runtime_engine": normalize_runtime_engine(
payload.get("runtime_engine", current.get("runtime_engine", DEFAULT_RUNTIME_ENGINE)),
),
"soul": _validate_text_field(
"soul",
payload.get("soul", current.get("soul", "")),
Expand Down Expand Up @@ -109,13 +115,18 @@ def _write_profile_md(workspace: dict[str, Any], profile: dict[str, Any]) -> Non
lines = ["# Agent Profile", ""]
if profile.get("agent_type"):
lines.extend([f"**Type:** `{profile['agent_type']}`", ""])
runtime_engine = str(profile.get("runtime_engine") or "").strip()
if runtime_engine:
lines.extend([f"**Runtime:** `{runtime_engine}`", ""])
if profile.get("soul"):
lines.extend(["## Identity (SOUL)", "", str(profile["soul"]), ""])
if profile.get("paradigm"):
lines.extend(["## Work Paradigm", "", str(profile["paradigm"]), ""])
if profile.get("standards"):
lines.extend(["## Standards", "", str(profile["standards"]), ""])
defaults: list[str] = []
if runtime_engine:
defaults.append(f"- Runtime engine: `{runtime_engine}`")
if profile.get("default_model"):
defaults.append(f"- Default model: `{profile['default_model']}`")
if profile.get("default_skills"):
Expand All @@ -134,15 +145,18 @@ def profile_context_sections(profile: dict[str, Any]) -> list[str]:
"""Markdown sections to append to AGENT_CONTEXT.md."""
sections: list[str] = []
agent_type = str(profile.get("agent_type") or "").strip()
runtime_engine = str(profile.get("runtime_engine") or "").strip()
soul = str(profile.get("soul") or "").strip()
paradigm = str(profile.get("paradigm") or "").strip()
standards = str(profile.get("standards") or "").strip()
if not any([agent_type, soul, paradigm, standards]):
if not any([agent_type, runtime_engine, soul, paradigm, standards]):
return sections

sections.extend(["## Agent Profile", ""])
if agent_type:
sections.append(f"- **Type:** `{agent_type}`")
if runtime_engine:
sections.append(f"- **Runtime engine:** `{runtime_engine}`")
if soul or paradigm or standards:
sections.append("- Persistent profile from console settings (`.evotown/profile.json`)")
sections.append("")
Expand Down
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ psutil>=5.9.0
tiktoken>=0.5.0
# Hosted Claude Coding Agent (embeds Claude Code CLI binary)
claude-agent-sdk>=0.2.93
# Hosted Codex SDK (optional runtime; dry-run when not installed)
openai-codex>=0.1.0
# Auth — password hashing
bcrypt>=4.0
# Database drivers (testing + MCP dynamic services)
Expand Down
82 changes: 68 additions & 14 deletions backend/services/claude_code_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,8 +728,8 @@ def _sdk_ready() -> bool:
return bool(claude_agent_sdk_runner.sdk_available() and (direct_key or (gateway_enabled and gateway_key)))


def _execution_backend() -> str:
"""Resolve run backend: embedded SDK (default), external CLI, or dry-run."""
def _claude_execution_backend() -> str:
"""Resolve Claude run backend: embedded SDK, external CLI, or dry-run."""
from services import claude_agent_sdk_runner

mode = os.environ.get("EVOTOWN_CLAUDE_EXECUTION_MODE", "auto").strip().lower()
Expand All @@ -748,8 +748,47 @@ def _execution_backend() -> str:
return "dry-run"


async def _run_agent(*, workspace_root: Path, prompt: str, run: dict[str, Any], model: str) -> tuple[int, str, str]:
backend = _execution_backend()
def _execution_backend(runtime_engine: str) -> str:
"""Resolve run backend for the workspace runtime engine."""
from services.runtime_engine import normalize_runtime_engine

if normalize_runtime_engine(runtime_engine) == "codex":
from services import codex_agent_sdk_runner

return codex_agent_sdk_runner.execution_backend()
return _claude_execution_backend()


async def _run_agent(
*,
workspace_root: Path,
prompt: str,
run: dict[str, Any],
model: str,
runtime_engine: str = "claude",
) -> tuple[int, str, str]:
from services.runtime_engine import normalize_runtime_engine

engine = normalize_runtime_engine(runtime_engine)
backend = _execution_backend(engine)
if engine == "codex":
if backend == "sdk":
from services import codex_agent_sdk_runner

exit_code, output = await codex_agent_sdk_runner.run_agent_sdk(
workspace_root=workspace_root,
prompt=prompt,
model=model,
run=run,
)
return exit_code, output, backend
summary = (
"Dry-run completed (Codex). Install openai-codex (pip install openai-codex) and set "
"OPENAI_API_KEY, or enable EVOTOWN_CODEX_USE_GATEWAY with EVOTOWN_CODEX_GATEWAY_API_KEY. "
"Workspace context files were written under .evotown/."
)
return 0, summary, "dry-run"

if backend == "sdk":
from services import claude_agent_sdk_runner

Expand Down Expand Up @@ -889,10 +928,23 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:

root = workspaces.resolve_workspace_path(workspace)
model = resolve_run_model(str(run.get("model") or ""))
signals = run.get("signals") or {}
from infra import workspace_profile as _wp
from services.runtime_engine import engine_id_for_runtime, normalize_runtime_engine

ws_profile = _wp.get_profile(workspace)
runtime_engine = normalize_runtime_engine(
signals.get("runtime_engine") or ws_profile.get("runtime_engine"),
)
engine_id = engine_id_for_runtime(runtime_engine)

claude_agent_runs.update_run_status(run_id, status="running")
claude_agent_runs.append_event(run_id, "context.prepare", {"workspace_root": str(root), "model": model})
claude_agent_runs.append_event(
run_id,
"context.prepare",
{"workspace_root": str(root), "model": model, "runtime_engine": runtime_engine},
)

signals = run.get("signals") or {}
selected_skills = list(signals.get("selected_skills") or [])
previous_run_id = str(signals.get("previous_run_id") or "").strip()
attachment_paths = [str(p).strip() for p in (signals.get("attachments") or []) if str(p).strip()]
Expand All @@ -901,9 +953,7 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
prompt = _build_conversation_prompt(run["prompt"], history)
prompt = _append_attachments_to_prompt(prompt, workspace, attachment_paths)

# Prepend identity profile to prompt so model sees it before Claude Code default identity
from infra import workspace_profile as _wp
ws_profile = _wp.get_profile(workspace)
# Prepend identity profile to prompt so model sees it before runner default identity
if ws_profile and ws_profile.get("soul"):
parts = [f"[SYSTEM IDENTITY - 你的身份设定]\n{ws_profile['soul']}"]
if ws_profile.get("paradigm"):
Expand Down Expand Up @@ -947,7 +997,6 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:

identity = _runner_identity(run)

ws_profile = _wp.get_profile(workspace)
shared_context = build_shared_context(
prompt=run["prompt"],
team_id=run.get("team_id", ""),
Expand Down Expand Up @@ -989,6 +1038,7 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
prompt=prompt,
run=run,
model=model,
runtime_engine=runtime_engine,
)
if timeout_sec > 0:
exit_code, output, execution_backend = await asyncio.wait_for(agent_coro, timeout=timeout_sec)
Expand All @@ -1006,7 +1056,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
"engine_id": DEFAULT_ENGINE_ID,
"engine_id": engine_id,
"runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
Expand All @@ -1028,7 +1079,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
"engine_id": DEFAULT_ENGINE_ID,
"engine_id": engine_id,
"runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
},
Expand All @@ -1046,7 +1098,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
"engine_id": DEFAULT_ENGINE_ID,
"engine_id": engine_id,
"runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
Expand Down Expand Up @@ -1085,7 +1138,8 @@ async def run_claude_agent(run_id: str) -> dict[str, Any]:
artifact_manifest=artifacts,
signals={
**(run.get("signals") or {}),
"engine_id": DEFAULT_ENGINE_ID,
"engine_id": engine_id,
"runtime_engine": runtime_engine,
"workspace_id": workspace["workspace_id"],
"execution_backend": execution_backend,
"sdk_command_configured": execution_backend != "dry-run",
Expand Down
Loading
Loading