feat(beaver): 完成Task Team功能v1实现,重构后端架构支持统一内核

新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证
(通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。

实现Agent Team v1协调器,支持sequence/parallel/dag执行策略,
sub-agent复用主AgentLoop,每个run使用独立memory snapshot。

建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期,
通过Task验证通过且用户满意才生成学习候选。

重构目录结构,移除third_party依赖,建立统一engine内核,
所有agent共享运行时基础组件。

更新ContextBuilder清理provider消息字段,增强SkillContext版本管理,
集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
This commit is contained in:
2026-05-08 17:14:14 +08:00
parent 5ba5c7e4c1
commit 8a12c30141
93 changed files with 16724 additions and 1247 deletions

View File

@ -2,16 +2,30 @@
from __future__ import annotations
import json
import asyncio
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config
from beaver.services.agent_service import AgentService
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from .deps import get_agent_service
from .schemas import WebChatRequest, WebChatResponse, WebErrorResponse, WebStatusResponse
from .schemas import (
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebStatusResponse,
)
try:
from fastapi import FastAPI, HTTPException, Request
@ -50,6 +64,24 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
return decorator
def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def patch(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
@asynccontextmanager
async def _app_lifespan(
@ -82,9 +114,28 @@ async def _app_lifespan(
else:
attached_service.close()
raise
worker: SkillLearningWorker | None = None
worker_task = None
worker_config = SkillLearningWorkerConfig.from_env()
if owns_service and worker_config.enabled:
loaded = attached_service.create_loop().boot()
worker = SkillLearningWorker(
pipeline=loaded.skill_learning_pipeline, # type: ignore[arg-type]
provider_bundle_factory=lambda: attached_service._make_provider_bundle_for_task(loaded, {}), # noqa: SLF001
config=worker_config,
)
worker_task = asyncio.create_task(worker.run_forever())
app.state.skill_learning_worker = worker
app.state.skill_learning_worker_task = worker_task
try:
yield
finally:
if worker is not None:
worker.stop()
if worker_task is not None:
worker_task.cancel()
with suppress(BaseException):
await worker_task
if owns_service and started:
await attached_service.shutdown(
timeout_seconds=shutdown_timeout_seconds,
@ -133,6 +184,412 @@ def create_app(
mode="running" if running else ("direct" if agent_service.has_loop else "idle"),
)
@app.get("/api/status")
async def status(request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
config = loaded.config
config_path = config.config_path or default_config_path(workspace=loaded.workspace)
providers_status = []
default_provider = config.resolve_provider_target().get("provider_name")
for spec in PROVIDERS:
provider_cfg = config.providers.get(spec.name)
enabled = provider_cfg is not None
api_key = provider_cfg.api_key if provider_cfg is not None else None
api_base = provider_cfg.api_base if provider_cfg is not None else None
if spec.is_oauth:
has_key = enabled
elif spec.is_local or spec.is_direct:
has_key = bool(api_base)
else:
has_key = bool(api_key)
providers_status.append(
{
"id": spec.name,
"name": spec.label,
"label": spec.label,
"enabled": enabled,
"active": default_provider == spec.name,
"has_key": has_key,
"api_key_masked": _mask_secret(api_key),
"api_base": api_base or "",
"default_api_base": spec.default_api_base,
"detail": api_base or spec.default_api_base or "",
"requires_api_key": not (spec.is_oauth or spec.is_local or spec.is_direct),
"is_oauth": spec.is_oauth,
"is_local": spec.is_local,
}
)
return {
"config_path": str(config_path),
"config_exists": config_path.exists(),
"workspace": str(loaded.workspace),
"workspace_exists": loaded.workspace.exists(),
"model": config.default_model or agent_service.profile.default_model,
"max_tokens": agent_service.profile.max_tokens,
"temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status,
"channels": [{"name": "web", "enabled": True}],
"cron": {"enabled": False, "jobs": 0, "next_wake_at_ms": None},
}
@app.post("/api/providers/{provider_name}/config", response_model=WebProviderConfigResponse)
async def update_provider_config(
provider_name: str,
request: Request,
payload: WebProviderConfigRequest,
) -> WebProviderConfigResponse:
spec = find_by_name(provider_name)
if spec is None:
raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_name}")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
providers = _ensure_dict(raw, "providers")
agents = _ensure_dict(raw, "agents")
defaults = _ensure_dict(agents, "defaults")
if not payload.enabled:
providers.pop(spec.name, None)
if _clean_text(defaults.get("provider")) == spec.name:
defaults.pop("provider", None)
else:
current = providers.get(spec.name) if isinstance(providers.get(spec.name), dict) else {}
provider_payload = dict(current)
api_key = _clean_text(payload.api_key)
api_base = _clean_text(payload.api_base)
if api_key:
provider_payload["apiKey"] = api_key
elif "apiKey" not in provider_payload and "api_key" not in provider_payload:
provider_payload.pop("apiKey", None)
if api_base:
provider_payload["apiBase"] = api_base
elif spec.default_api_base and not provider_payload.get("apiBase") and not provider_payload.get("api_base"):
provider_payload["apiBase"] = spec.default_api_base
elif not api_base and not spec.default_api_base:
provider_payload.pop("apiBase", None)
if payload.request_timeout_seconds is not None:
provider_payload["requestTimeoutSeconds"] = payload.request_timeout_seconds
providers[spec.name] = provider_payload
defaults["provider"] = spec.name
model = _clean_text(payload.model)
if model:
defaults["model"] = model
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
@app.get("/api/sessions")
async def list_sessions(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
rows = session_manager.list_sessions_rich(limit=100, exclude_sources=["subagent"]) # type: ignore[union-attr]
return [
{
"key": str(row.get("id")),
"created_at": _iso_from_timestamp(row.get("started_at")),
"updated_at": _iso_from_timestamp(row.get("last_active")),
"path": str(row.get("id")),
}
for row in rows
]
@app.post("/api/sessions/{session_id:path}")
async def create_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
session = session_manager.get_or_create(session_id, source="web") # type: ignore[union-attr]
return _session_detail(session_manager, session_id, session) # type: ignore[arg-type]
@app.get("/api/sessions/{session_id:path}/process")
async def get_session_process(session_id: str, request: Request) -> dict[str, Any]:
from beaver.services.process_service import SessionProcessProjector
loaded = get_agent_service(request).create_loop().boot()
projector = SessionProcessProjector(
loaded.session_manager,
loaded.run_memory_store,
)
return projector.project(session_id)
@app.get("/api/sessions/{session_id:path}")
async def get_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
session = session_manager.get_or_create(session_id, source="web") # type: ignore[union-attr]
return _session_detail(session_manager, session_id, session) # type: ignore[arg-type]
@app.delete("/api/sessions/{session_id:path}")
async def delete_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
loaded.session_manager.end_session(session_id, "deleted") # type: ignore[union-attr]
return {"ok": True}
@app.get("/api/agents")
async def list_agents(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()] # type: ignore[union-attr]
@app.post("/api/agents")
async def upsert_agent(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
agent = loaded.agent_registry.upsert_agent(_agent_payload_from_ui(payload)) # type: ignore[union-attr]
return _registered_agent_to_ui(agent)
@app.patch("/api/agents/{agent_id}")
async def patch_agent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
registry = loaded.agent_registry
current = registry.get_agent(agent_id) # type: ignore[union-attr]
if current is None:
raise HTTPException(status_code=404, detail=f"Unknown agent: {agent_id}")
merged = current.to_dict()
merged.update(_agent_payload_from_ui(payload))
merged["agent_id"] = agent_id
agent = registry.upsert_agent(merged) # type: ignore[union-attr]
return _registered_agent_to_ui(agent)
@app.post("/api/agents/{agent_id}/disable")
async def disable_agent(agent_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
agent = loaded.agent_registry.disable_agent(agent_id) # type: ignore[union-attr]
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _registered_agent_to_ui(agent)
@app.get("/api/skills")
async def list_skills(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
skills = loaded.skills_loader.list_skills(filter_unavailable=False) # type: ignore[union-attr]
return [
{
"name": record.name,
"description": record.description,
"source": "builtin" if record.source == "builtin" else "workspace",
"available": loaded.skills_loader._record_available(record), # type: ignore[union-attr]
"path": str(record.path),
"agent_cards": [],
}
for record in skills
]
@app.get("/api/skills/candidates")
async def list_skill_candidates(request: Request, status: str | None = None) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [item.to_dict() for item in loaded.skill_learning_pipeline.list_candidates(status=status)] # type: ignore[union-attr]
@app.get("/api/skills/candidates/{candidate_id}")
async def get_skill_candidate(candidate_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return loaded.skill_learning_pipeline.get_candidate(candidate_id).to_dict() # type: ignore[union-attr]
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/skills/candidates/{candidate_id}/draft")
async def synthesize_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
try:
draft = await loaded.skill_learning_pipeline.synthesize_draft( # type: ignore[union-attr]
candidate_id,
provider_bundle=provider_bundle,
)
loaded.skill_learning_pipeline.check_safety(draft.skill_name, draft.draft_id) # type: ignore[union-attr]
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
draft.skill_name,
draft.draft_id,
provider_bundle=provider_bundle,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return draft.to_dict()
@app.post("/api/skills/candidates/{candidate_id}/regenerate")
async def regenerate_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
try:
draft = await loaded.skill_learning_pipeline.regenerate_draft( # type: ignore[union-attr]
candidate_id,
provider_bundle=provider_bundle,
)
loaded.skill_learning_pipeline.check_safety(draft.skill_name, draft.draft_id) # type: ignore[union-attr]
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
draft.skill_name,
draft.draft_id,
provider_bundle=provider_bundle,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return draft.to_dict()
@app.post("/api/skills/learning/run-once")
async def run_skill_learning_once(request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
worker = SkillLearningWorker(
pipeline=loaded.skill_learning_pipeline, # type: ignore[arg-type]
provider_bundle_factory=lambda: agent_service._make_provider_bundle_for_task(loaded, {}), # noqa: SLF001
config=SkillLearningWorkerConfig.from_env(),
)
result = await worker.run_once()
return result.to_dict()
@app.get("/api/skills/drafts")
async def list_skill_drafts(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
results = []
for item in loaded.skill_learning_pipeline.list_drafts(): # type: ignore[union-attr]
safety = loaded.skill_learning_pipeline.get_safety_report(item.skill_name, item.draft_id) # type: ignore[union-attr]
eval_report = loaded.skill_learning_pipeline.get_eval_report(item.skill_name, item.draft_id) # type: ignore[union-attr]
results.append(
{
**item.to_dict(),
"safety_report": safety.to_dict() if safety is not None else None,
"eval_report": eval_report.to_dict() if eval_report is not None else None,
}
)
return results
@app.get("/api/skills/{skill_name}/drafts/{draft_id}")
async def get_skill_draft(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return {
**draft.to_dict(),
"reviews": [
item.to_dict()
for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id) # type: ignore[union-attr]
],
"safety_report": (
loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id).to_dict() # type: ignore[union-attr]
if loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) is not None # type: ignore[union-attr]
else None
),
"eval_report": (
loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id).to_dict() # type: ignore[union-attr]
if loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) is not None # type: ignore[union-attr]
else None
),
}
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/safety")
async def get_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
report = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) # type: ignore[union-attr]
if report is None:
raise HTTPException(status_code=404, detail="Safety report not found")
return report.to_dict()
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr]
if report is None:
raise HTTPException(status_code=404, detail="Eval report not found")
return report.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/submit")
async def submit_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.submit_review( # type: ignore[union-attr]
skill_name,
draft_id,
requested_by=str((payload or {}).get("requested_by") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/approve")
async def approve_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.approve( # type: ignore[union-attr]
skill_name,
draft_id,
reviewer=str((payload or {}).get("reviewer") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/reject")
async def reject_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.reject( # type: ignore[union-attr]
skill_name,
draft_id,
reviewer=str((payload or {}).get("reviewer") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/publish")
async def publish_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
result = loaded.skill_learning_pipeline.publish( # type: ignore[union-attr]
skill_name,
draft_id,
publisher=str((payload or {}).get("publisher") or "web"),
notes=str((payload or {}).get("notes") or ""),
confirm_high_risk=bool((payload or {}).get("confirm_high_risk")),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result.to_dict()
@app.post("/api/skills/{skill_name}/disable")
async def disable_skill(skill_name: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
spec = loaded.skill_learning_pipeline.disable( # type: ignore[union-attr]
skill_name,
actor=str((payload or {}).get("actor") or "web"),
reason=str((payload or {}).get("reason") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return spec.to_dict()
@app.post("/api/skills/{skill_name}/rollback")
async def rollback_skill(skill_name: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
target_version = str(payload.get("target_version") or "").strip()
if not target_version:
raise HTTPException(status_code=400, detail="target_version is required")
loaded = get_agent_service(request).create_loop().boot()
try:
spec = loaded.skill_learning_pipeline.rollback( # type: ignore[union-attr]
skill_name,
target_version,
actor=str(payload.get("actor") or "web"),
reason=str(payload.get("reason") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return spec.to_dict()
@app.post(
"/api/chat",
response_model=WebChatResponse,
@ -191,11 +648,132 @@ def create_app(
provider_name=result.provider_name,
model=result.model,
usage=result.usage,
task_id=result.task_id,
task_status=result.task_status,
validation_result=result.validation_result,
)
@app.post(
"/api/chat/feedback",
response_model=WebChatFeedbackResponse,
responses={
400: {"model": WebErrorResponse},
404: {"model": WebErrorResponse},
},
)
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_feedback(
session_id=payload.session_id,
run_id=payload.run_id,
feedback_type=payload.feedback_type,
comment=payload.comment,
)
except ValueError as exc:
detail = str(exc)
status_code = 404 if "No internal task" in detail else 400
raise HTTPException(status_code=status_code, detail=detail) from exc
return WebChatFeedbackResponse(**result)
return app
def _session_detail(session_manager: Any, session_id: str, session: dict[str, Any]) -> dict[str, Any]:
messages = []
for event in session_manager.get_messages_as_conversation(session_id):
role = event.get("role")
if role not in {"user", "assistant"}:
continue
messages.append(
{
"role": role,
"content": event.get("content") or "",
"timestamp": _iso_from_timestamp(event.get("timestamp")),
"run_id": event.get("run_id"),
"task_id": event.get("task_id"),
"task_status": event.get("task_status"),
"validation_status": event.get("validation_status"),
"feedback_state": event.get("feedback_state"),
"feedback_error": event.get("feedback_error"),
}
)
return {
"key": session_id,
"messages": messages,
"created_at": _iso_from_timestamp(session.get("started_at")),
"updated_at": _iso_from_timestamp(session.get("last_active")),
}
def _iso_from_timestamp(value: Any) -> str:
from datetime import datetime, timezone
if value in (None, ""):
return datetime.now(timezone.utc).isoformat()
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
except (TypeError, ValueError):
return str(value)
def _registered_agent_to_ui(agent: Any) -> dict[str, Any]:
return {
"id": agent.agent_id,
"name": agent.display_name or agent.name,
"description": agent.description,
"source": agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace",
"kind": "specialist",
"protocol": None,
"endpoint": None,
"base_url": None,
"card_url": None,
"auth_env": None,
"auth_mode": "none",
"auth_audience": None,
"auth_scopes": [],
"tags": list(agent.tags),
"aliases": [agent.name],
"metadata": {
**dict(agent.metadata),
"role": agent.role,
"capabilities": list(agent.capabilities),
"skill_names": list(agent.skill_names),
"tool_hints": list(agent.tool_hints),
"priority": agent.priority,
"status": agent.status,
},
"support_streaming": False,
}
def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]:
metadata = dict(payload.get("metadata") or {})
capabilities = payload.get("capabilities")
if capabilities is None and isinstance(metadata.get("capabilities"), list):
capabilities = metadata.get("capabilities")
role = payload.get("role") or metadata.get("role") or payload.get("kind") or ""
return {
"agent_id": payload.get("agent_id") or payload.get("id") or payload.get("name"),
"name": payload.get("name") or payload.get("id"),
"display_name": payload.get("display_name") or payload.get("name") or payload.get("id"),
"role": role,
"description": payload.get("description") or "",
"system_prompt": payload.get("system_prompt") or metadata.get("system_prompt") or "",
"capabilities": capabilities or [],
"skill_names": payload.get("skill_names") or metadata.get("skill_names") or [],
"tool_hints": payload.get("tool_hints") or metadata.get("tool_hints") or [],
"model": payload.get("model") or metadata.get("model"),
"provider_name": payload.get("provider_name") or metadata.get("provider_name"),
"tags": payload.get("tags") or [],
"priority": payload.get("priority") or metadata.get("priority") or 0,
"status": payload.get("status") or ("active" if payload.get("enabled", True) else "disabled"),
"source": payload.get("source") or "workspace",
"metadata": metadata,
}
def _model_dump(value: Any) -> dict[str, Any] | None:
"""兼容 Pydantic v1/v2 的最小导出辅助。"""
@ -206,3 +784,52 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
if hasattr(value, "dict"):
return value.dict(exclude_none=True)
return dict(value)
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _mask_secret(value: str | None) -> str:
secret = _clean_text(value)
if not secret:
return ""
if len(secret) <= 8:
return "••••"
return f"{secret[:4]}••••{secret[-4:]}"
def _read_config_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Config must be a JSON object: {path}")
return data
def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]:
value = parent.get(key)
if not isinstance(value, dict):
value = {}
parent[key] = value
return value
def _write_config_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
config = load_config(config_path=config_path)
agent_service.loader.config = config
loop = getattr(agent_service, "_loop", None)
loaded = getattr(loop, "loaded", None) if loop is not None else None
if loaded is not None:
loaded.config = config

View File

@ -1,11 +1,25 @@
"""Web request and response schemas."""
from .chat import WebChatRequest, WebChatResponse, WebErrorResponse, WebProviderTarget, WebStatusResponse
from .chat import (
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebProviderTarget,
WebStatusResponse,
)
__all__ = [
"WebChatFeedbackRequest",
"WebChatFeedbackResponse",
"WebChatRequest",
"WebChatResponse",
"WebErrorResponse",
"WebProviderConfigRequest",
"WebProviderConfigResponse",
"WebProviderTarget",
"WebStatusResponse",
]

View File

@ -77,6 +77,47 @@ class WebChatResponse(BaseModel):
provider_name: str | None = None
model: str | None = None
usage: dict[str, Any] = Field(default_factory=dict)
task_id: str | None = None
task_status: str | None = None
validation_result: dict[str, Any] | None = None
class WebChatFeedbackRequest(BaseModel):
"""Feedback on the latest assistant result in chat."""
session_id: str
run_id: str
feedback_type: str
comment: str | None = None
class WebChatFeedbackResponse(BaseModel):
"""Feedback recording result."""
session_id: str
run_id: str
task_id: str
task_status: str
feedback_type: str
learning_candidates: list[dict[str, Any]] = Field(default_factory=list)
class WebProviderConfigRequest(BaseModel):
"""Provider config update from the status page."""
enabled: bool = True
model: str | None = None
api_key: str | None = None
api_base: str | None = None
request_timeout_seconds: float | None = None
class WebProviderConfigResponse(BaseModel):
"""Provider config update result."""
ok: bool
provider: str
enabled: bool
class WebStatusResponse(BaseModel):