From d95d06233f73a45b32967702ca83f8e9304ebf53 Mon Sep 17 00:00:00 2001 From: wuyang <5700876+banisherwy@user.noreply.gitee.com> Date: Sun, 26 Jul 2026 18:50:51 +0800 Subject: [PATCH] refactor: use a single agent model path --- README.md | 57 ++++++------ agent_platform/bootstrap.py | 35 +++++-- agent_platform/models.py | 94 ++++++++++++------- agent_platform/runtime/__init__.py | 2 +- agent_platform/runtime/app.py | 18 +--- agent_platform/runtime/loop.py | 6 +- agent_platform/runtime/tools.py | 6 +- agent_platform/store.py | 45 --------- compose.yaml | 4 +- deploy/docker-compose.yml | 4 +- docker/web/ModelSelector.svelte | 96 ++++++-------------- docs/architecture.md | 42 +++------ docs/infrastructure.md | 7 +- docs/openwebui-integration.md | 19 ++-- docs/operations.md | 31 ++++++- e2e/stub_provider.py | 7 +- e2e/verify_stack.py | 60 +++++------- scripts/eval-live-work.py | 6 +- tests/test_agent_loop.py | 28 +++--- tests/test_bootstrap.py | 20 ++-- tests/test_models.py | 44 +++++---- tests/test_runtime_api.py | 141 +++++++++-------------------- tests/test_store.py | 12 --- 23 files changed, 330 insertions(+), 454 deletions(-) diff --git a/README.md b/README.md index 5a09359..b07f1d6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,8 @@ # K1412 Agent -K1412 Agent is a multi-user web Agent built around two deliberately different -experiences: - -- **Chat** is fast, conversational, and uses Open WebUI's native tool loop. -- **Work** is a long-running coding Agent whose loop, scheduler, context, - memory, tools, and sub-agents are owned by this repository. - -A conversation may be upgraded from Chat to Work. The upgrade is intentionally -one-way so that two independent Agent loops never compete for the same -conversation state. +K1412 Agent is a multi-user web coding Agent. Every user request enters the +same independently evolvable Agent loop; its scheduler, context policy, memory, +tools, evidence rules, and child Agents are owned by this repository. The production instance is available at . @@ -22,7 +15,7 @@ browser Open WebUI (auth, RBAC, chat history, UI) | v -Agent Runtime (model gateway + Work loop) +Agent Runtime (model gateway + Agent loop) | v Workspace Gateway (identity, policy, audit) @@ -31,19 +24,24 @@ Workspace Gateway (identity, policy, audit) one Docker workspace per Open WebUI user on a dedicated execution host ``` -Open WebUI is pinned and lightly patched. Its model picker is replaced by two -product-level choices: `Chat / Work` and `轻度 / 中 / 高 / 极高`. Provider URLs, -provider model IDs, API keys, tool server settings, system prompts, and runtime -parameters remain server-side. +Open WebUI is pinned and lightly patched. Its picker exposes only four model +names and a separate read-only thinking status. Provider URLs, provider model +IDs, API keys, tool server settings, system prompts, and runtime parameters +remain server-side. -The inference mapping is fixed: +Model identity and thinking control are intentionally separate: -| Strength | Provider model | -| --- | --- | -| 轻度 | `ChatGPT-5.6:Luna` | -| 中 | `ChatGPT-5.6:Terra` | -| 高 | `ChatGPT-5.6:Sol` | -| 极高 | `deepseek-v4-pro`(Thinking,`reasoning_effort=max`) | +| UI model | Provider model | Thinking | Reasoning effort | +| --- | --- | --- | --- | +| Luna | `ChatGPT-5.6:Luna` | On, no effort levels | — | +| Terra | `ChatGPT-5.6:Terra` | On, no effort levels | — | +| Sol | `ChatGPT-5.6:Sol` | On, no effort levels | — | +| DeepSeek V4 Pro | `deepseek-v4-pro` | On | Extreme (`max`) | + +The three Ollama models advertise `thinking`, but they are Qwen-family models +with a boolean thinking switch rather than GPT-OSS-style low/medium/high +effort levels. Luna, Terra, and Sol are different models, not three reasoning +settings for one model. ## Local development @@ -64,9 +62,8 @@ The inference mapping is fixed: 4. Open . New users register as `pending` and require approval by the bootstrap administrator. -The pasted provider credential from the planning conversation is intentionally -not stored here. Rotate it and place the replacement only in the protected -deployment `.env`. +Provider credentials are intentionally not stored here. Keep them only in the +protected deployment `.env`. ## Tests @@ -83,14 +80,14 @@ test, with: ./scripts/verify.sh ``` -Run a live Work evaluation in a unique, disposable, network-disabled Docker +Run a live Agent evaluation in a unique, disposable, network-disabled Docker workspace with: ```bash set -a source /path/to/protected/deepseek.env set +a -.venv/bin/python scripts/eval-live-work.py --model work-extreme +.venv/bin/python scripts/eval-live-work.py --model deepseek-v4-pro ``` The evaluator reports latency, token usage, tool counts, completion evidence, @@ -98,8 +95,8 @@ and the generated file listing. It removes only the uniquely named evaluation container, volume, and network unless `--keep-workspace` is supplied. Run the disposable six-service integration stack with a deterministic fake -model provider and exercise registration approval, both Agent loops, workspace -isolation, and the one-way mode upgrade with: +model provider and exercise registration approval, the Agent loop, workspace +isolation, and file delivery with: ```bash ./scripts/verify-e2e.sh @@ -116,7 +113,7 @@ High/Critical image vulnerabilities, with: ./scripts/audit-images.sh ``` -Generated files are available from the **工作区文件** button beside the mode +Generated files are available from the **工作区文件** button beside the model selector. Users can browse directories, download one file, or download the current directory as a `.tar.gz` archive. Browser requests are authenticated by Open WebUI and re-signed for the Workspace Gateway; no internal key is exposed. diff --git a/agent_platform/bootstrap.py b/agent_platform/bootstrap.py index 9a5213a..90d1d85 100644 --- a/agent_platform/bootstrap.py +++ b/agent_platform/bootstrap.py @@ -9,6 +9,10 @@ import httpx from agent_platform.models import MODEL_SPECS +LEGACY_MODEL_IDS = { + f"{mode}-{strength}" for mode in ("chat", "work") for strength in ("light", "medium", "high", "extreme") +} + def _required(name: str) -> str: value = os.getenv(name, "").strip() @@ -33,27 +37,24 @@ def _model_payload() -> list[dict[str, Any]]: public_read = [{"principal_type": "user", "principal_id": "*", "permission": "read"}] items = [] for spec in MODEL_SPECS.values(): - is_chat = spec.mode == "chat" items.append( { "id": spec.public_id, "base_model_id": None, "name": spec.display_name, - "params": {"function_calling": "native"} if is_chat else {}, + "params": {}, "meta": { - "description": ( - "快速问答,使用原生工具循环。" if is_chat else "长链路工作,使用 K1412 Work Agent。" - ), - "toolIds": ["server:workspace"] if is_chat else [], + "description": (f"K1412 Agent;思考:{spec.thinking_label}。"), + "toolIds": [], "capabilities": { - "tool_calling": is_chat, + "tool_calling": False, "builtin_tools": False, "file_context": False, "citations": False, "vision": False, "file_upload": False, }, - "tags": [{"name": "Chat" if is_chat else "Work"}], + "tags": [{"name": "Agent"}], }, "access_grants": public_read, "is_active": True, @@ -151,6 +152,22 @@ async def bootstrap() -> None: ) tool_server_response.raise_for_status() + for model_id in sorted(LEGACY_MODEL_IDS): + existing = await client.get( + f"{base_url}/api/v1/models/model", + headers=headers, + params={"id": model_id}, + ) + if existing.status_code == 404: + continue + existing.raise_for_status() + delete_response = await client.post( + f"{base_url}/api/v1/models/model/delete", + headers=headers, + json={"id": model_id}, + ) + delete_response.raise_for_status() + model_response = await client.post( f"{base_url}/api/v1/models/import", headers=headers, @@ -158,7 +175,7 @@ async def bootstrap() -> None: ) model_response.raise_for_status() - print("Open WebUI bootstrap complete: auth policy, workspace tools, and eight fixed Agent entries are ready.") + print("Open WebUI bootstrap complete: auth policy, workspace tools, and four Agent models are ready.") def run() -> None: diff --git a/agent_platform/models.py b/agent_platform/models.py index 989b5be..c8ac1ea 100644 --- a/agent_platform/models.py +++ b/agent_platform/models.py @@ -3,8 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -Mode = Literal["chat", "work"] -Strength = Literal["light", "medium", "high", "extreme"] Provider = Literal["k1412", "deepseek"] @@ -12,49 +10,70 @@ Provider = Literal["k1412", "deepseek"] class ModelSpec: public_id: str display_name: str - mode: Mode - strength: Strength provider: Provider provider_model: str thinking_enabled: bool + thinking_label: str + thinking_adjustable: bool reasoning_effort: str | None max_output_tokens: int max_iterations: int context_char_budget: int -_TIERS = { - "light": ("轻度", "k1412", "ChatGPT-5.6:Luna", False, None, 4_096, 8, 120_000), - "medium": ("中", "k1412", "ChatGPT-5.6:Terra", False, None, 4_096, 16, 240_000), - "high": ("高", "k1412", "ChatGPT-5.6:Sol", False, None, 4_096, 24, 400_000), - "extreme": ("极高", "deepseek", "deepseek-v4-pro", True, "max", 16_384, 32, 800_000), -} - MODEL_SPECS: dict[str, ModelSpec] = { - f"{mode}-{strength}": ModelSpec( - public_id=f"{mode}-{strength}", - display_name=f"{'Chat' if mode == 'chat' else 'Work'} · {label}", - mode=mode, - strength=strength, - provider=provider, - provider_model=provider_model, - thinking_enabled=thinking_enabled, - reasoning_effort=reasoning_effort, - max_output_tokens=max_output_tokens, - max_iterations=max_iterations, - context_char_budget=context_budget, - ) - for mode in ("chat", "work") - for strength, ( - label, - provider, - provider_model, - thinking_enabled, - reasoning_effort, - max_output_tokens, - max_iterations, - context_budget, - ) in _TIERS.items() + "luna": ModelSpec( + public_id="luna", + display_name="Luna", + provider="k1412", + provider_model="ChatGPT-5.6:Luna", + thinking_enabled=True, + thinking_label="开启(不分档)", + thinking_adjustable=False, + reasoning_effort=None, + max_output_tokens=4_096, + max_iterations=8, + context_char_budget=120_000, + ), + "terra": ModelSpec( + public_id="terra", + display_name="Terra", + provider="k1412", + provider_model="ChatGPT-5.6:Terra", + thinking_enabled=True, + thinking_label="开启(不分档)", + thinking_adjustable=False, + reasoning_effort=None, + max_output_tokens=4_096, + max_iterations=16, + context_char_budget=240_000, + ), + "sol": ModelSpec( + public_id="sol", + display_name="Sol", + provider="k1412", + provider_model="ChatGPT-5.6:Sol", + thinking_enabled=True, + thinking_label="开启(不分档)", + thinking_adjustable=False, + reasoning_effort=None, + max_output_tokens=4_096, + max_iterations=24, + context_char_budget=400_000, + ), + "deepseek-v4-pro": ModelSpec( + public_id="deepseek-v4-pro", + display_name="DeepSeek V4 Pro", + provider="deepseek", + provider_model="deepseek-v4-pro", + thinking_enabled=True, + thinking_label="极高", + thinking_adjustable=False, + reasoning_effort="max", + max_output_tokens=16_384, + max_iterations=32, + context_char_budget=800_000, + ), } @@ -74,6 +93,11 @@ def openai_model_list() -> dict: "object": "model", "owned_by": "k1412-agent", "name": spec.display_name, + "k1412": { + "thinking_enabled": spec.thinking_enabled, + "thinking_label": spec.thinking_label, + "thinking_adjustable": spec.thinking_adjustable, + }, } for spec in MODEL_SPECS.values() ], diff --git a/agent_platform/runtime/__init__.py b/agent_platform/runtime/__init__.py index f0bfb37..23f39c9 100644 --- a/agent_platform/runtime/__init__.py +++ b/agent_platform/runtime/__init__.py @@ -1 +1 @@ -"""Model gateway and independently evolvable Work Agent runtime.""" +"""Model gateway and independently evolvable K1412 Agent runtime.""" diff --git a/agent_platform/runtime/app.py b/agent_platform/runtime/app.py index 6296b08..a6e267d 100644 --- a/agent_platform/runtime/app.py +++ b/agent_platform/runtime/app.py @@ -10,7 +10,7 @@ from typing import Annotated, Any import httpx import uvicorn -from fastapi import FastAPI, Header, HTTPException, status +from fastapi import FastAPI, Header, HTTPException from fastapi.responses import JSONResponse, StreamingResponse from agent_platform.auth import UserIdentity, decode_openwebui_identity, verify_service_bearer @@ -198,16 +198,6 @@ def create_app( return await forward_direct() stable_chat_id = (chat_id or message_id or f"ephemeral-{uuid.uuid4().hex}").strip() - selected_mode = await app.state.store.select_mode(identity.user_id, chat_id or "", spec.mode) - if selected_mode == "work" and spec.mode == "chat": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="This conversation has been upgraded to Work and cannot return to Chat.", - ) - - if spec.mode == "chat": - return await forward_direct() - messages = [message.model_dump(exclude_none=True) for message in body.messages] if not body.stream: @@ -224,7 +214,7 @@ def create_app( ) return _completion(body.model, answer) - async def work_stream() -> AsyncIterator[bytes]: + async def agent_stream() -> AsyncIterator[bytes]: queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() async def publish(event_type: str, payload: dict[str, Any]) -> None: @@ -255,7 +245,7 @@ def create_app( if kind == "done": break if kind == "error": - yield _sse_chunk(body.model, f"\n\nWork 运行失败:{value}") + yield _sse_chunk(body.model, f"\n\nAgent 运行失败:{value}") continue if kind == "answer": text = str(value) @@ -271,7 +261,7 @@ def create_app( await asyncio.gather(task, return_exceptions=True) return StreamingResponse( - work_stream(), + agent_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) diff --git a/agent_platform/runtime/loop.py b/agent_platform/runtime/loop.py index 5b45aac..c1c074f 100644 --- a/agent_platform/runtime/loop.py +++ b/agent_platform/runtime/loop.py @@ -28,7 +28,7 @@ from agent_platform.store import RuntimeStore EventCallback = Callable[[str, dict[str, Any]], Awaitable[None]] WORK_SYSTEM_PROMPT = """\ -You are Work, an autonomous coding Agent operating in one isolated user workspace at /workspace. +You are K1412 Agent, an autonomous coding Agent operating in one isolated user workspace at /workspace. Own the outcome: inspect the workspace, form a plan for non-trivial work, make scoped changes, run relevant verification, and report concrete results. Use tools instead of inventing file contents or @@ -514,8 +514,8 @@ class AgentLoop: await recorder.emit( "run.created", { - "model_tier": spec.strength, - "strategy_version": "work-loop-v2", + "model_id": spec.public_id, + "strategy_version": "agent-loop-v3", "scheduler_version": "safe-parallel-v1", "context_policy": "recent-visible-v1", }, diff --git a/agent_platform/runtime/tools.py b/agent_platform/runtime/tools.py index fcc48f2..c76a467 100644 --- a/agent_platform/runtime/tools.py +++ b/agent_platform/runtime/tools.py @@ -200,14 +200,14 @@ TOOL_METADATA: dict[str, ToolMetadata] = { ), "remember": ToolMetadata( "remember", - "Store a durable user preference or fact that will help future Work tasks.", + "Store a durable user preference or fact that will help future Agent tasks.", object_schema({"content": {"type": "string"}}, ["content"]), False, False, ), "recall_memory": ToolMetadata( "recall_memory", - "Search durable Work memory for this user.", + "Search durable Agent memory for this user.", object_schema( { "query": {"type": "string", "default": ""}, @@ -219,7 +219,7 @@ TOOL_METADATA: dict[str, ToolMetadata] = { ), "forget_memory": ToolMetadata( "forget_memory", - "Delete one durable Work memory by id.", + "Delete one durable Agent memory by id.", object_schema({"memory_id": {"type": "string"}}, ["memory_id"]), False, False, diff --git a/agent_platform/store.py b/agent_platform/store.py index cc590d6..d7181ec 100644 --- a/agent_platform/store.py +++ b/agent_platform/store.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -16,17 +15,6 @@ class Base(DeclarativeBase): pass -class ConversationMode(Base): - __tablename__ = "agent_conversation_modes" - __table_args__ = (UniqueConstraint("user_id", "chat_id"),) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[str] = mapped_column(String(128), index=True) - chat_id: Mapped[str] = mapped_column(String(256), index=True) - mode: Mapped[str] = mapped_column(String(16)) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - - class RunEvent(Base): __tablename__ = "agent_run_events" @@ -64,8 +52,6 @@ class RuntimeStore: def __init__(self, url: str) -> None: self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True) self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) - self._mode_locks: dict[tuple[str, str], asyncio.Lock] = {} - self._lock_guard = asyncio.Lock() async def initialize(self) -> None: async with self.engine.begin() as connection: @@ -79,37 +65,6 @@ class RuntimeStore: async with self.sessions() as session: yield session - async def _mode_lock(self, user_id: str, chat_id: str) -> asyncio.Lock: - key = (user_id, chat_id) - async with self._lock_guard: - return self._mode_locks.setdefault(key, asyncio.Lock()) - - async def select_mode(self, user_id: str, chat_id: str, requested: str) -> str: - if requested not in {"chat", "work"}: - raise ValueError("Invalid conversation mode") - if not chat_id: - return requested - lock = await self._mode_lock(user_id, chat_id) - async with lock: - async with self.session() as session: - row = await session.scalar( - select(ConversationMode).where( - ConversationMode.user_id == user_id, - ConversationMode.chat_id == chat_id, - ) - ) - if row is None: - session.add(ConversationMode(user_id=user_id, chat_id=chat_id, mode=requested)) - await session.commit() - return requested - if row.mode == "work" and requested == "chat": - return "work" - if row.mode != requested: - row.mode = requested - row.updated_at = datetime.now(UTC) - await session.commit() - return row.mode - async def append_event( self, run_id: str, diff --git a/compose.yaml b/compose.yaml index 90ab97d..8292d32 100644 --- a/compose.yaml +++ b/compose.yaml @@ -35,8 +35,8 @@ services: K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required} OPENAI_API_BASE_URL: http://runtime:8000/v1 OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required} - DEFAULT_MODELS: chat-medium - TASK_MODEL: chat-light + DEFAULT_MODELS: terra + TASK_MODEL: luna ENABLE_TITLE_GENERATION: "false" ENABLE_FOLLOW_UP_GENERATION: "false" ENABLE_TAGS_GENERATION: "false" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index bcf3f11..e79efb7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -35,8 +35,8 @@ services: K1412_GATEWAY_KEY: ${INTERNAL_GATEWAY_KEY:?INTERNAL_GATEWAY_KEY is required} OPENAI_API_BASE_URL: http://runtime:8000/v1 OPENAI_API_KEY: ${INTERNAL_PROVIDER_KEY:?INTERNAL_PROVIDER_KEY is required} - DEFAULT_MODELS: chat-medium - TASK_MODEL: chat-light + DEFAULT_MODELS: terra + TASK_MODEL: luna ENABLE_TITLE_GENERATION: "false" ENABLE_FOLLOW_UP_GENERATION: "false" ENABLE_TAGS_GENERATION: "false" diff --git a/docker/web/ModelSelector.svelte b/docker/web/ModelSelector.svelte index 911e5d2..56580ab 100644 --- a/docker/web/ModelSelector.svelte +++ b/docker/web/ModelSelector.svelte @@ -7,96 +7,58 @@ export let showSetDefault = true; let showWorkspace = false; - const strengths = [ - { key: 'light', label: '轻度' }, - { key: 'medium', label: '中' }, - { key: 'high', label: '高' }, - { key: 'extreme', label: '极高' } + const modelOptions = [ + { id: 'luna', label: 'Luna', thinking: '开启 · 不分档' }, + { id: 'terra', label: 'Terra', thinking: '开启 · 不分档' }, + { id: 'sol', label: 'Sol', thinking: '开启 · 不分档' }, + { id: 'deepseek-v4-pro', label: 'DeepSeek', thinking: '极高' } ]; + const allowed = new Set(modelOptions.map((item) => item.id)); - const allowed = new Set([ - 'chat-light', - 'chat-medium', - 'chat-high', - 'chat-extreme', - 'work-light', - 'work-medium', - 'work-high', - 'work-extreme' - ]); - - $: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'chat-medium'; - $: mode = selected.startsWith('work-') ? 'work' : 'chat'; - $: strength = selected.split('-')[1] ?? 'medium'; + $: selected = allowed.has(selectedModels?.[0]) ? selectedModels[0] : 'terra'; + $: selectedOption = modelOptions.find((item) => item.id === selected) ?? modelOptions[1]; $: availableIds = new Set($models.map((model) => model.id)); $: if ($models.length > 0 && !availableIds.has(selectedModels?.[0])) { - const fallback = availableIds.has('chat-medium') - ? 'chat-medium' - : [...allowed].find((id) => availableIds.has(id)); + const fallback = availableIds.has('terra') + ? 'terra' + : modelOptions.find((item) => availableIds.has(item.id))?.id; if (fallback) selectedModels = [fallback]; } - const chooseMode = (nextMode: 'chat' | 'work') => { - if (disabled || (mode === 'work' && nextMode === 'chat')) return; - const next = `${nextMode}-${strength}`; - if (availableIds.has(next)) selectedModels = [next]; - }; - - const chooseStrength = (nextStrength: string) => { - if (disabled) return; - const next = `${mode}-${nextStrength}`; - if (availableIds.has(next)) selectedModels = [next]; + const chooseModel = (modelId: string) => { + if (disabled || !availableIds.has(modelId)) return; + selectedModels = [modelId]; }; -
+
- - -
- -
- {#each strengths as item} + {#each modelOptions as item} {/each}
+
+ 思考 {selectedOption.thinking} +
+