refactor: use a single agent model path
This commit is contained in:
@@ -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 <https://agent.k1412.top>.
|
||||
|
||||
@@ -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 <http://localhost:3000>. 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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+59
-35
@@ -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()
|
||||
],
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Model gateway and independently evolvable Work Agent runtime."""
|
||||
"""Model gateway and independently evolvable K1412 Agent runtime."""
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex max-w-fit items-center gap-2 select-none">
|
||||
<div class="flex max-w-full items-center gap-2 select-none">
|
||||
<div
|
||||
class="flex items-center rounded-xl bg-gray-100/90 p-1 text-sm dark:bg-gray-800/90"
|
||||
aria-label="Agent mode"
|
||||
class="flex items-center overflow-x-auto rounded-xl border border-gray-200/80 p-1 text-xs dark:border-gray-700/80"
|
||||
aria-label="模型"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 font-medium transition {mode === 'chat'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
on:click={() => chooseMode('chat')}
|
||||
disabled={disabled || mode === 'work'}
|
||||
title={mode === 'work' ? '此对话已升级为 Work,不能返回 Chat' : '快速对话'}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 font-medium transition {mode === 'work'
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
on:click={() => chooseMode('work')}
|
||||
{disabled}
|
||||
title="长链路、多步骤工作"
|
||||
>
|
||||
Work
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center rounded-xl border border-gray-200/80 p-1 text-xs dark:border-gray-700/80"
|
||||
aria-label="Inference strength"
|
||||
>
|
||||
{#each strengths as item}
|
||||
{#each modelOptions as item}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-2.5 py-1.5 transition {strength === item.key
|
||||
class="whitespace-nowrap rounded-lg px-2.5 py-1.5 font-medium transition {selected === item.id
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800'}"
|
||||
on:click={() => chooseStrength(item.key)}
|
||||
{disabled}
|
||||
on:click={() => chooseModel(item.id)}
|
||||
disabled={disabled || ($models.length > 0 && !availableIds.has(item.id))}
|
||||
title={item.id === 'deepseek-v4-pro' ? 'DeepSeek V4 Pro' : item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="whitespace-nowrap rounded-xl bg-gray-100/90 px-2.5 py-2 text-xs text-gray-500 dark:bg-gray-800/90 dark:text-gray-400"
|
||||
title="模型与思考能力是两个独立概念"
|
||||
>
|
||||
思考 <span class="font-medium text-gray-800 dark:text-gray-200">{selectedOption.thinking}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl border border-gray-200/80 p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:border-gray-700/80 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
|
||||
+15
-27
@@ -1,25 +1,12 @@
|
||||
# Architecture
|
||||
|
||||
## Product modes
|
||||
## Single Agent path
|
||||
|
||||
Chat and Work share the same authenticated user, conversation history, and
|
||||
workspace, but not the same Agent loop.
|
||||
Open WebUI owns authenticated users, conversation history, and rendering.
|
||||
Every normal model request is delegated to the K1412 Runtime. There is no
|
||||
second Chat loop and no per-conversation mode state.
|
||||
|
||||
| Concern | Chat | Work |
|
||||
| --- | --- | --- |
|
||||
| Primary purpose | Fast conversation | Multi-step deliverables |
|
||||
| Loop owner | Open WebUI native loop | K1412 Agent Runtime |
|
||||
| Tools | Workspace Gateway through OpenAPI | Workspace Gateway through Runtime |
|
||||
| Visible history | Open WebUI | Open WebUI |
|
||||
| Run events | Open WebUI | Runtime event store |
|
||||
| Context and memory | Open WebUI defaults are disabled by policy | Runtime policy and store |
|
||||
| Workspace files | Authenticated K1412 file browser | Same file browser and volume |
|
||||
|
||||
Selecting Work atomically upgrades `(user_id, chat_id)` in the Runtime store.
|
||||
Subsequent Chat requests for that conversation are rejected even if a client
|
||||
tries to bypass the disabled UI control.
|
||||
|
||||
## Work loop
|
||||
## Agent loop
|
||||
|
||||
The first strategy is intentionally modular:
|
||||
|
||||
@@ -27,21 +14,22 @@ The first strategy is intentionally modular:
|
||||
- `ContextPolicy` owns visible-message cleanup and compaction.
|
||||
- `ToolRegistry` owns tool schemas and dispatch.
|
||||
- `AgentLoop` owns iteration, safe parallel scheduling, and delegation.
|
||||
- `RuntimeStore` owns modes, events, plans, and durable Work memory.
|
||||
- `RuntimeStore` owns events, plans, and durable Agent memory.
|
||||
- `ExecutionProvider` owns local or SSH Docker execution.
|
||||
|
||||
Every run records its model tier, strategy version, scheduler version, context
|
||||
Every run records its model ID, strategy version, scheduler version, context
|
||||
policy, model/tool events, failures, and completion. This event stream is the
|
||||
foundation for replay, evaluation, and future experiment assignment.
|
||||
|
||||
The light, medium, and high tiers use the internal K1412 model endpoint. The
|
||||
extreme tier uses DeepSeek V4 Pro with thinking enabled and maximum reasoning
|
||||
effort. Provider selection and credentials remain server-side. Thinking state
|
||||
is preserved across tool turns because DeepSeek requires the assistant's
|
||||
`reasoning_content` to be returned with the following tool results; Work never
|
||||
publishes that hidden state as its own user-facing reasoning.
|
||||
Luna, Terra, and Sol are three independent Ollama models. Each advertises
|
||||
boolean thinking support, but none advertises adjustable reasoning effort.
|
||||
DeepSeek V4 Pro is a fourth model configured with thinking enabled and maximum
|
||||
reasoning effort. Provider selection and credentials remain server-side.
|
||||
Thinking state is preserved across tool turns when the provider requires the
|
||||
assistant's `reasoning_content` to be returned with following tool results.
|
||||
The Agent never publishes hidden reasoning as its own user-facing output.
|
||||
|
||||
Work completion is evidence-gated. Requests for scripts, reports, code, or
|
||||
Completion is evidence-gated. Requests for scripts, reports, code, or
|
||||
other concrete artifacts are not accepted as complete until a file mutation
|
||||
has succeeded and a later read, diff, or command has verified the latest
|
||||
mutation. Bare interactive shell commands are rejected. If the iteration
|
||||
|
||||
@@ -27,6 +27,7 @@ them does not change the Agent loop:
|
||||
| k1412 homepage | Service discovery entry |
|
||||
| Tailscale | Private NAS-to-execution-host network |
|
||||
| Unraid Compose Manager | Application lifecycle on the NAS |
|
||||
| Ollama + 32 GB NVIDIA GPU | Luna, Terra, and Sol model serving |
|
||||
|
||||
Other NAS applications are independent tenants. They are not dependencies of
|
||||
K1412 Agent merely because they share the host.
|
||||
@@ -35,8 +36,8 @@ K1412 Agent merely because they share the host.
|
||||
|
||||
| Container | Responsibility | Agent core? |
|
||||
| --- | --- | --- |
|
||||
| `k1412-agent-web` | Open WebUI auth, RBAC, history, UI, Chat loop | Product shell |
|
||||
| `k1412-agent-runtime` | Work loop and provider gateway | Yes |
|
||||
| `k1412-agent-web` | Open WebUI auth, RBAC, history, and UI | Product shell |
|
||||
| `k1412-agent-runtime` | Agent loop and provider gateway | Yes |
|
||||
| `k1412-agent-gateway` | Identity, execution policy, workspace lifecycle | Yes |
|
||||
| `k1412-agent-postgres` | Open WebUI and Runtime durable state | State |
|
||||
| `k1412-agent-redis` | Open WebUI coordination/cache | State |
|
||||
@@ -68,7 +69,7 @@ The code intended for continuous experimentation is:
|
||||
- `agent_platform/runtime/loop.py`: loop and evidence policy;
|
||||
- `agent_platform/runtime/context.py`: context policy;
|
||||
- `agent_platform/runtime/tools.py`: tools and scheduling metadata;
|
||||
- `agent_platform/store.py`: events, plans, mode state, memory;
|
||||
- `agent_platform/store.py`: events, plans, and memory;
|
||||
- `agent_platform/gateway/`: isolated execution providers.
|
||||
|
||||
Frontend branding, reverse proxy configuration, and registry automation are
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
## Responsibility split
|
||||
|
||||
Open WebUI is the product shell. It owns account registration, login sessions,
|
||||
RBAC, administrator approval, conversation history, rendering, and the Chat
|
||||
mode Agent loop. K1412 does not fork those responsibilities into a second
|
||||
authentication or chat database.
|
||||
RBAC, administrator approval, conversation history, and rendering. K1412 does
|
||||
not fork those responsibilities into a second authentication or chat database.
|
||||
|
||||
K1412 Runtime owns Work mode: context selection, durable Work memory, planning,
|
||||
tool schemas, scheduling, parallel reads, child Agents, evidence gating,
|
||||
experiments, and run-event persistence. Workspace Gateway owns identity-bound
|
||||
execution policy and Docker lifecycle.
|
||||
K1412 Runtime owns the only user-facing Agent loop: context selection, durable
|
||||
memory, planning, tool schemas, scheduling, parallel reads, child Agents,
|
||||
evidence gating, experiments, and run-event persistence. Workspace Gateway
|
||||
owns identity-bound execution policy and Docker lifecycle.
|
||||
|
||||
This split makes the expensive experimental surface small. A new scheduler or
|
||||
context policy changes Runtime, not the account system or entire frontend.
|
||||
@@ -19,7 +18,7 @@ context policy changes Runtime, not the account system or entire frontend.
|
||||
|
||||
Open WebUI natively understands tool-call and reasoning detail blocks. It also
|
||||
has a richer live status mechanism for loops that execute inside its own
|
||||
backend. Work is an external OpenAI-compatible provider, so it cannot mutate
|
||||
backend. K1412 Runtime is an external OpenAI-compatible provider, so it cannot mutate
|
||||
Open WebUI's internal socket status objects directly.
|
||||
|
||||
K1412 therefore emits Open WebUI-compatible completed tool detail blocks. Each
|
||||
@@ -52,8 +51,8 @@ Upgrading Open WebUI requires:
|
||||
1. updating the tag and commit pin;
|
||||
2. replaying every patch with `git apply`;
|
||||
3. building the Svelte production bundle;
|
||||
4. running authentication, Chat, Work, and file-browser E2E tests;
|
||||
4. running authentication, Agent-loop, isolation, and file-browser E2E tests;
|
||||
5. reviewing upstream license changes.
|
||||
|
||||
The K1412 Agent loop is not embedded into Open WebUI, so most Work experiments
|
||||
The K1412 Agent loop is not embedded into Open WebUI, so most Agent experiments
|
||||
do not incur this upgrade cost.
|
||||
|
||||
+27
-4
@@ -41,12 +41,35 @@ proxy.
|
||||
|
||||
Set `DEEPSEEK_API_BASE_URL=https://api.deepseek.com` and place
|
||||
`DEEPSEEK_API_KEY` only in the protected deployment `.env`. The key is used
|
||||
only by Runtime for the extreme tier and must never be added to a browser,
|
||||
only by Runtime for DeepSeek V4 Pro and must never be added to a browser,
|
||||
Compose file, image, repository, or log.
|
||||
|
||||
The SSH override removes `/var/run/docker.sock` from Gateway and mounts the
|
||||
dedicated SSH directory read-only at `/root/.ssh`.
|
||||
|
||||
## Ollama model residency
|
||||
|
||||
The NAS Ollama service keeps Luna, Terra, and Sol resident with:
|
||||
|
||||
- `OLLAMA_KEEP_ALIVE=-1`;
|
||||
- `OLLAMA_MAX_LOADED_MODELS=3`;
|
||||
- `OLLAMA_NUM_PARALLEL=1`;
|
||||
- `OLLAMA_GPU_OVERHEAD=2147483648`;
|
||||
- Flash Attention and `q8_0` KV cache enabled.
|
||||
|
||||
The three 32K-context runners were measured at approximately 2.88 GB, 4.12 GB,
|
||||
and 18.44 GB of GPU allocation respectively. Their combined allocation is
|
||||
about 25.44 GB on the 32 GB GPU. Do not increase context size or per-model
|
||||
parallelism without repeating the simultaneous-load test and leaving capacity
|
||||
for inference overhead.
|
||||
|
||||
The persistent Unraid template is
|
||||
`/boot/config/plugins/dockerMan/templates-user/my-ollama.xml`. A locked,
|
||||
idempotent five-minute check in
|
||||
`/boot/config/plugins/dynamix/k1412-ollama.cron` prewarms only missing models
|
||||
after a host or container restart. Confirm `/api/ps` lists all three with an
|
||||
infinite expiry after recovery.
|
||||
|
||||
## Workspace migration
|
||||
|
||||
Before changing execution hosts:
|
||||
@@ -78,9 +101,9 @@ RUN_DOCKER_INTEGRATION=1 .venv/bin/pytest tests/test_docker_workspace.py
|
||||
Production smoke checks must cover:
|
||||
|
||||
- anonymous requests are redirected/rejected;
|
||||
- an approved user can use Chat;
|
||||
- Chat can upgrade to Work but cannot downgrade;
|
||||
- Work creates and verifies a real file;
|
||||
- an approved user sees exactly four Agent models;
|
||||
- each model routes through the custom Agent loop;
|
||||
- the Agent creates and verifies a real file;
|
||||
- the file browser lists and downloads that file;
|
||||
- a second user cannot see it;
|
||||
- Gateway health reports `ssh-docker`;
|
||||
|
||||
@@ -69,12 +69,11 @@ class StubHandler(BaseHTTPRequestHandler):
|
||||
available_names = {
|
||||
str((tool.get("function") or {}).get("name", "")) for tool in tools if isinstance(tool, dict)
|
||||
}
|
||||
is_work = "update_plan" in available_names
|
||||
is_agent = "update_plan" in available_names
|
||||
|
||||
if tools and not has_tool_result:
|
||||
path = "work-proof.txt" if is_work else "chat-proof.txt"
|
||||
arguments = json.dumps(
|
||||
{"path": path, "content": _last_user_text(messages) or "e2e"},
|
||||
{"path": "work-proof.txt", "content": _last_user_text(messages) or "e2e"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
message = {
|
||||
@@ -91,7 +90,7 @@ class StubHandler(BaseHTTPRequestHandler):
|
||||
self._respond(payload, _completion(model, message, "tool_calls"))
|
||||
return
|
||||
|
||||
if is_work and "write_file" in called_tools and "read_file" not in called_tools:
|
||||
if is_agent and "write_file" in called_tools and "read_file" not in called_tools:
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
|
||||
+24
-36
@@ -26,14 +26,10 @@ INTERNAL_PROVIDER_KEY = os.getenv(
|
||||
"e2e-provider-secret-at-least-32-bytes",
|
||||
)
|
||||
EXPECTED_MODELS = {
|
||||
"chat-light",
|
||||
"chat-medium",
|
||||
"chat-high",
|
||||
"chat-extreme",
|
||||
"work-light",
|
||||
"work-medium",
|
||||
"work-high",
|
||||
"work-extreme",
|
||||
"luna",
|
||||
"terra",
|
||||
"sol",
|
||||
"deepseek-v4-pro",
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +52,6 @@ def _stream_chat(
|
||||
model: str,
|
||||
chat_id: str,
|
||||
prompt: str,
|
||||
include_workspace_tools: bool,
|
||||
) -> tuple[int, str]:
|
||||
user_message_id = uuid.uuid4().hex
|
||||
assistant_message_id = uuid.uuid4().hex
|
||||
@@ -75,11 +70,9 @@ def _stream_chat(
|
||||
"content": prompt,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
"params": {"function_calling": "native"},
|
||||
"params": {},
|
||||
"features": {},
|
||||
}
|
||||
if include_workspace_tools:
|
||||
payload["tool_ids"] = ["server:workspace"]
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/api/chat/completions",
|
||||
@@ -208,37 +201,25 @@ def main() -> None:
|
||||
|
||||
chat_ids = [f"local:e2e-{suffix}-a", f"local:e2e-{suffix}-b"]
|
||||
for index, user in enumerate(users):
|
||||
marker = f"E2E_CHAT_USER_{index}_{suffix}"
|
||||
marker = f"E2E_AGENT_USER_{index}_{suffix}"
|
||||
status_code, body = _stream_chat(
|
||||
client,
|
||||
user["token"],
|
||||
model="chat-light",
|
||||
model="luna" if index == 0 else "terra",
|
||||
chat_id=chat_ids[index],
|
||||
prompt=marker,
|
||||
include_workspace_tools=True,
|
||||
)
|
||||
assert status_code == 200, body
|
||||
|
||||
docker_client = docker.from_env()
|
||||
assert _workspace_file(docker_client, users[0]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_0_{suffix}"
|
||||
assert _workspace_file(docker_client, users[1]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_1_{suffix}"
|
||||
assert _workspace_file(docker_client, users[0]["id"], "work-proof.txt") == f"E2E_AGENT_USER_0_{suffix}"
|
||||
assert _workspace_file(docker_client, users[1]["id"], "work-proof.txt") == f"E2E_AGENT_USER_1_{suffix}"
|
||||
for user in users:
|
||||
ref = workspace_ref(user["id"])
|
||||
workspace = docker_client.containers.get(ref.container_name)
|
||||
workspace.reload()
|
||||
assert set(workspace.attrs["NetworkSettings"]["Networks"]) == {ref.network_name}
|
||||
|
||||
work_marker = f"E2E_WORK_USER_0_{suffix}"
|
||||
status_code, work_body = _stream_chat(
|
||||
client,
|
||||
users[0]["token"],
|
||||
model="work-medium",
|
||||
chat_id=chat_ids[0],
|
||||
prompt=work_marker,
|
||||
include_workspace_tools=False,
|
||||
)
|
||||
assert status_code == 200, work_body
|
||||
assert _workspace_file(docker_client, users[0]["id"], "work-proof.txt") == work_marker
|
||||
listing = client.get(
|
||||
"/api/v1/k1412/workspace/files",
|
||||
headers=_auth(users[0]["token"]),
|
||||
@@ -252,14 +233,21 @@ def main() -> None:
|
||||
params={"path": "work-proof.txt"},
|
||||
)
|
||||
download.raise_for_status()
|
||||
assert download.text == work_marker
|
||||
assert download.text == f"E2E_AGENT_USER_0_{suffix}"
|
||||
isolated_listing = client.get(
|
||||
"/api/v1/k1412/workspace/files",
|
||||
headers=_auth(users[1]["token"]),
|
||||
params={"path": "."},
|
||||
)
|
||||
isolated_listing.raise_for_status()
|
||||
assert "work-proof.txt" not in {item["name"] for item in isolated_listing.json()["entries"]}
|
||||
assert "work-proof.txt" in {item["name"] for item in isolated_listing.json()["entries"]}
|
||||
isolated_download = client.get(
|
||||
"/api/v1/k1412/workspace/download",
|
||||
headers=_auth(users[1]["token"]),
|
||||
params={"path": "work-proof.txt"},
|
||||
)
|
||||
isolated_download.raise_for_status()
|
||||
assert isolated_download.text == f"E2E_AGENT_USER_1_{suffix}"
|
||||
archive = client.get(
|
||||
"/api/v1/k1412/workspace/archive",
|
||||
headers=_auth(users[0]["token"]),
|
||||
@@ -276,20 +264,20 @@ def main() -> None:
|
||||
event_types = {event["type"] for event in events.json()["items"]}
|
||||
assert {"run.created", "tool.completed", "run.completed"} <= event_types
|
||||
|
||||
downgrade = runtime.post(
|
||||
legacy = runtime.post(
|
||||
"/v1/chat/completions",
|
||||
headers=runtime_headers,
|
||||
json={
|
||||
"model": "chat-high",
|
||||
"messages": [{"role": "user", "content": "THIS_DOWNGRADE_MUST_FAIL"}],
|
||||
"model": "work-high",
|
||||
"messages": [{"role": "user", "content": "LEGACY_MODEL_MUST_FAIL"}],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert downgrade.status_code == 409, downgrade.text
|
||||
assert legacy.status_code == 404, legacy.text
|
||||
|
||||
print(
|
||||
"E2E passed: auth approval, eight models, Chat tools, Work evidence, file downloads, "
|
||||
"isolation, and downgrade lock."
|
||||
"E2E passed: auth approval, four Agent models, custom loop evidence, file downloads, "
|
||||
"and per-user workspace isolation."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@ class DirectDockerRegistry:
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the real Work loop against a live provider in a disposable Docker workspace."
|
||||
description="Run the real Agent loop against a live provider in a disposable Docker workspace."
|
||||
)
|
||||
parser.add_argument("--model", default="work-extreme")
|
||||
parser.add_argument("--model", default="deepseek-v4-pro")
|
||||
parser.add_argument(
|
||||
"--prompt",
|
||||
default="写脚本对比一下几个常见排序算法,给一个报告",
|
||||
@@ -153,8 +153,6 @@ async def cleanup_workspace(execution: DockerExecutionProvider, user_id: str) ->
|
||||
async def main() -> None:
|
||||
args = parse_args()
|
||||
spec = get_model_spec(args.model)
|
||||
if spec.mode != "work":
|
||||
raise SystemExit("--model must select a Work tier")
|
||||
if spec.provider == "deepseek" and not os.getenv("DEEPSEEK_API_KEY", "").strip():
|
||||
raise SystemExit("DEEPSEEK_API_KEY is required")
|
||||
|
||||
|
||||
+14
-14
@@ -201,7 +201,7 @@ async def test_tool_call_batch_is_bounded(settings) -> None:
|
||||
|
||||
try:
|
||||
answer = await loop.run(
|
||||
spec=get_model_spec("work-medium"),
|
||||
spec=get_model_spec("terra"),
|
||||
messages=[{"role": "user", "content": "检查当前状态"}],
|
||||
identity=UserIdentity("u1", "u1@example.test", "U1", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -245,7 +245,7 @@ async def test_read_only_tool_calls_run_in_parallel(settings) -> None:
|
||||
|
||||
try:
|
||||
answer = await loop.run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "inspect both"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -314,7 +314,7 @@ async def test_mutating_tool_calls_are_serialized(settings) -> None:
|
||||
|
||||
try:
|
||||
await loop.run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "write both"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -374,7 +374,7 @@ async def test_artifact_completion_is_rejected_until_written_and_verified(settin
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写一个报告"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -469,7 +469,7 @@ async def test_script_and_report_completion_requires_both_files(settings) -> Non
|
||||
store,
|
||||
max_tool_output_chars=10_000,
|
||||
).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写一个脚本并给一个报告"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -575,7 +575,7 @@ async def test_failed_execution_must_be_repaired_before_completion(settings) ->
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写脚本对比排序算法"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -662,7 +662,7 @@ async def test_unchanged_failed_command_is_blocked_until_a_repair(settings) -> N
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -707,7 +707,7 @@ async def test_duplicate_commands_in_one_model_response_execute_once(settings) -
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -753,7 +753,7 @@ async def test_duplicate_identical_writes_execute_once(settings) -> None:
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写脚本并运行"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -815,7 +815,7 @@ async def test_script_delivery_requires_successful_execution(settings) -> None:
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写一个 Python 脚本"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -846,7 +846,7 @@ async def test_rejected_checkpoint_does_not_spin_without_tool_calls(settings) ->
|
||||
store,
|
||||
max_tool_output_chars=10_000,
|
||||
).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "写一个报告"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -888,7 +888,7 @@ async def test_bare_interactive_exec_is_blocked_before_registry(settings) -> Non
|
||||
store,
|
||||
max_tool_output_chars=10_000,
|
||||
).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "你好"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -926,7 +926,7 @@ async def test_unchanged_invalid_python_write_is_not_revalidated(settings) -> No
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-light"),
|
||||
spec=get_model_spec("luna"),
|
||||
messages=[{"role": "user", "content": "你好"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
@@ -999,7 +999,7 @@ async def test_extreme_tier_preserves_reasoning_state_across_tool_turns(settings
|
||||
|
||||
try:
|
||||
answer = await AgentLoop(provider, registry, store, max_tool_output_chars=10_000).run(
|
||||
spec=get_model_spec("work-extreme"),
|
||||
spec=get_model_spec("deepseek-v4-pro"),
|
||||
messages=[{"role": "user", "content": "读取 notes.txt 并检查内容"}],
|
||||
identity=UserIdentity("u1", "", "", "user"),
|
||||
raw_user_jwt="jwt",
|
||||
|
||||
+8
-12
@@ -1,20 +1,16 @@
|
||||
from agent_platform.bootstrap import _model_payload
|
||||
from agent_platform.bootstrap import LEGACY_MODEL_IDS, _model_payload
|
||||
|
||||
|
||||
def test_bootstrap_models_override_provider_ids_and_are_public() -> None:
|
||||
def test_bootstrap_models_are_agent_only_and_public() -> None:
|
||||
models = _model_payload()
|
||||
assert len(models) == 8
|
||||
assert {model["id"] for model in models} == {"luna", "terra", "sol", "deepseek-v4-pro"}
|
||||
assert all(model["base_model_id"] is None for model in models)
|
||||
assert all(model["params"] == {} for model in models)
|
||||
assert all(model["meta"]["toolIds"] == [] for model in models)
|
||||
assert all(model["meta"]["tags"] == [{"name": "Agent"}] for model in models)
|
||||
assert all(
|
||||
model["access_grants"] == [{"principal_type": "user", "principal_id": "*", "permission": "read"}]
|
||||
for model in models
|
||||
)
|
||||
|
||||
chat = next(model for model in models if model["id"] == "chat-medium")
|
||||
assert chat["params"]["function_calling"] == "native"
|
||||
assert chat["meta"]["toolIds"] == ["server:workspace"]
|
||||
assert chat["meta"]["capabilities"]["builtin_tools"] is False
|
||||
|
||||
work = next(model for model in models if model["id"] == "work-medium")
|
||||
assert work["params"] == {}
|
||||
assert work["meta"]["toolIds"] == []
|
||||
assert "chat-medium" in LEGACY_MODEL_IDS
|
||||
assert "work-medium" in LEGACY_MODEL_IDS
|
||||
|
||||
+24
-20
@@ -1,23 +1,27 @@
|
||||
from agent_platform.models import MODEL_SPECS, get_model_spec, openai_model_list
|
||||
|
||||
|
||||
def test_fixed_public_model_matrix() -> None:
|
||||
assert set(MODEL_SPECS) == {
|
||||
"chat-light",
|
||||
"chat-medium",
|
||||
"chat-high",
|
||||
"chat-extreme",
|
||||
"work-light",
|
||||
"work-medium",
|
||||
"work-high",
|
||||
"work-extreme",
|
||||
}
|
||||
assert get_model_spec("chat-light").provider_model == "ChatGPT-5.6:Luna"
|
||||
assert get_model_spec("work-medium").provider_model == "ChatGPT-5.6:Terra"
|
||||
assert get_model_spec("work-high").provider_model == "ChatGPT-5.6:Sol"
|
||||
extreme = get_model_spec("work-extreme")
|
||||
assert extreme.provider == "deepseek"
|
||||
assert extreme.provider_model == "deepseek-v4-pro"
|
||||
assert extreme.thinking_enabled is True
|
||||
assert extreme.reasoning_effort == "max"
|
||||
assert {item["id"] for item in openai_model_list()["data"]} == set(MODEL_SPECS)
|
||||
def test_fixed_public_model_catalog_separates_model_and_thinking() -> None:
|
||||
assert set(MODEL_SPECS) == {"luna", "terra", "sol", "deepseek-v4-pro"}
|
||||
assert get_model_spec("luna").provider_model == "ChatGPT-5.6:Luna"
|
||||
assert get_model_spec("terra").provider_model == "ChatGPT-5.6:Terra"
|
||||
assert get_model_spec("sol").provider_model == "ChatGPT-5.6:Sol"
|
||||
|
||||
for model_id in ("luna", "terra", "sol"):
|
||||
spec = get_model_spec(model_id)
|
||||
assert spec.thinking_enabled is True
|
||||
assert spec.thinking_adjustable is False
|
||||
assert spec.thinking_label == "开启(不分档)"
|
||||
assert spec.reasoning_effort is None
|
||||
|
||||
deepseek = get_model_spec("deepseek-v4-pro")
|
||||
assert deepseek.provider == "deepseek"
|
||||
assert deepseek.provider_model == "deepseek-v4-pro"
|
||||
assert deepseek.thinking_enabled is True
|
||||
assert deepseek.thinking_adjustable is False
|
||||
assert deepseek.thinking_label == "极高"
|
||||
assert deepseek.reasoning_effort == "max"
|
||||
|
||||
public_models = openai_model_list()["data"]
|
||||
assert {item["id"] for item in public_models} == set(MODEL_SPECS)
|
||||
assert all("k1412" in item for item in public_models)
|
||||
|
||||
+44
-97
@@ -18,7 +18,7 @@ class FakeModelProvider:
|
||||
async def complete(self, **kwargs):
|
||||
self.completed.append(kwargs)
|
||||
return {
|
||||
"choices": [{"message": {"role": "assistant", "content": "work complete"}, "finish_reason": "stop"}],
|
||||
"choices": [{"message": {"role": "assistant", "content": "agent complete"}, "finish_reason": "stop"}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
@@ -26,25 +26,13 @@ class FakeModelProvider:
|
||||
self.forwarded.append(payload)
|
||||
self.forward_options.append(kwargs)
|
||||
request = httpx.Request("POST", "https://model.example.test/v1/chat/completions")
|
||||
if payload.get("stream"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=(
|
||||
'data: {"model":"ChatGPT-5.6:Luna","choices":[{"index":0,"delta":'
|
||||
'{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":'
|
||||
'{"name":"read_file","arguments":"{}"}}]},"finish_reason":null}]}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
),
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
request=request,
|
||||
headers={"content-type": "application/json"},
|
||||
json={
|
||||
"model": payload["model"],
|
||||
"choices": [{"message": {"role": "assistant", "content": "chat reply"}, "finish_reason": "stop"}],
|
||||
"choices": [{"message": {"role": "assistant", "content": "background reply"}}],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,135 +59,94 @@ def headers(settings, identity_jwt, chat_id="chat-1"):
|
||||
}
|
||||
|
||||
|
||||
def test_model_list_requires_internal_key(settings) -> None:
|
||||
app = create_app(
|
||||
def _app(settings, provider=None):
|
||||
return create_app(
|
||||
settings,
|
||||
store=RuntimeStore(settings.database_url),
|
||||
provider=FakeModelProvider(),
|
||||
provider=provider or FakeModelProvider(),
|
||||
tools=FakeTools(),
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
|
||||
|
||||
def test_model_list_requires_internal_key_and_exposes_four_models(settings) -> None:
|
||||
with TestClient(_app(settings)) as client:
|
||||
assert client.get("/v1/models").status_code == 401
|
||||
response = client.get(
|
||||
"/v1/models",
|
||||
headers={"Authorization": f"Bearer {settings.internal_provider_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["data"]) == 8
|
||||
|
||||
|
||||
def test_chat_maps_provider_model_and_work_upgrade_is_one_way(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
||||
with TestClient(app) as client:
|
||||
chat = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "chat-light", "messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert chat.status_code == 200
|
||||
assert provider.forwarded[0]["model"] == "ChatGPT-5.6:Luna"
|
||||
assert chat.json()["model"] == "chat-light"
|
||||
|
||||
extreme_chat = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "extreme-chat"),
|
||||
json={"model": "chat-extreme", "messages": [{"role": "user", "content": "think"}]},
|
||||
)
|
||||
assert extreme_chat.status_code == 200
|
||||
assert provider.forwarded[-1]["model"] == "deepseek-v4-pro"
|
||||
assert provider.forward_options[-1] == {
|
||||
"provider": "deepseek",
|
||||
"thinking_enabled": True,
|
||||
"reasoning_effort": "max",
|
||||
"max_output_tokens": 16_384,
|
||||
assert {item["id"] for item in response.json()["data"]} == {
|
||||
"luna",
|
||||
"terra",
|
||||
"sol",
|
||||
"deepseek-v4-pro",
|
||||
}
|
||||
|
||||
work = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "work-high", "messages": [{"role": "user", "content": "do it"}]},
|
||||
)
|
||||
assert work.status_code == 200
|
||||
assert provider.completed[-1]["model"] == "ChatGPT-5.6:Sol"
|
||||
|
||||
extreme_work = client.post(
|
||||
def test_every_user_model_runs_the_custom_agent_loop(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
with TestClient(_app(settings, provider)) as client:
|
||||
luna = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "extreme-work"),
|
||||
json={"model": "work-extreme", "messages": [{"role": "user", "content": "do it carefully"}]},
|
||||
headers=headers(settings, identity_jwt, "luna-run"),
|
||||
json={"model": "luna", "messages": [{"role": "user", "content": "hello"}]},
|
||||
)
|
||||
assert extreme_work.status_code == 200
|
||||
assert luna.status_code == 200
|
||||
assert luna.json()["model"] == "luna"
|
||||
assert provider.completed[-1]["model"] == "ChatGPT-5.6:Luna"
|
||||
assert provider.completed[-1]["thinking_enabled"] is True
|
||||
assert provider.forwarded == []
|
||||
|
||||
deepseek = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "deepseek-run"),
|
||||
json={"model": "deepseek-v4-pro", "messages": [{"role": "user", "content": "do it"}]},
|
||||
)
|
||||
assert deepseek.status_code == 200
|
||||
assert provider.completed[-1]["model"] == "deepseek-v4-pro"
|
||||
assert provider.completed[-1]["provider"] == "deepseek"
|
||||
assert provider.completed[-1]["reasoning_effort"] == "max"
|
||||
|
||||
downgrade = client.post(
|
||||
legacy = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt),
|
||||
json={"model": "chat-medium", "messages": [{"role": "user", "content": "back"}]},
|
||||
headers=headers(settings, identity_jwt, "legacy-run"),
|
||||
json={"model": "work-medium", "messages": [{"role": "user", "content": "old"}]},
|
||||
)
|
||||
assert downgrade.status_code == 409
|
||||
assert legacy.status_code == 404
|
||||
|
||||
|
||||
def test_chat_stream_hides_provider_model_and_preserves_native_tool_calls(settings, identity_jwt) -> None:
|
||||
def test_openwebui_background_task_bypasses_agent_loop(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "chat-stream"),
|
||||
json={
|
||||
"model": "chat-light",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": "inspect"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert '"model": "chat-light"' in response.text
|
||||
assert "ChatGPT-5.6:Luna" not in response.text
|
||||
assert '"tool_calls"' in response.text
|
||||
assert '"name": "read_file"' in response.text
|
||||
assert "data: [DONE]" in response.text
|
||||
|
||||
|
||||
def test_openwebui_background_task_bypasses_work_loop(settings, identity_jwt) -> None:
|
||||
provider = FakeModelProvider()
|
||||
app = create_app(settings, store=RuntimeStore(settings.database_url), provider=provider, tools=FakeTools())
|
||||
with TestClient(app) as client:
|
||||
with TestClient(_app(settings, provider)) as client:
|
||||
response = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "background-task"),
|
||||
json={
|
||||
"model": "work-medium",
|
||||
"model": "terra",
|
||||
"messages": [{"role": "user", "content": "Generate tags"}],
|
||||
"metadata": {"task": "tags_generation"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["model"] == "work-medium"
|
||||
assert response.json()["model"] == "terra"
|
||||
assert provider.completed == []
|
||||
assert provider.forwarded[-1]["model"] == "ChatGPT-5.6:Terra"
|
||||
|
||||
|
||||
def test_work_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
|
||||
app = create_app(
|
||||
settings,
|
||||
store=RuntimeStore(settings.database_url),
|
||||
provider=FakeModelProvider(),
|
||||
tools=FakeTools(),
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
def test_agent_stream_never_emits_delta_tool_calls(settings, identity_jwt) -> None:
|
||||
with TestClient(_app(settings)) as client:
|
||||
response = client.post(
|
||||
"/v1/chat/completions",
|
||||
headers=headers(settings, identity_jwt, "stream-chat"),
|
||||
json={
|
||||
"model": "work-medium",
|
||||
"model": "terra",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": "do it"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "work complete" in response.text
|
||||
assert "agent complete" in response.text
|
||||
for line in response.text.splitlines():
|
||||
if line.startswith("data: {"):
|
||||
chunk = json.loads(line.removeprefix("data: "))
|
||||
|
||||
@@ -3,18 +3,6 @@ from __future__ import annotations
|
||||
from agent_platform.store import RuntimeStore
|
||||
|
||||
|
||||
async def test_conversation_mode_upgrade_is_one_way(settings) -> None:
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
try:
|
||||
assert await store.select_mode("u1", "c1", "chat") == "chat"
|
||||
assert await store.select_mode("u1", "c1", "work") == "work"
|
||||
assert await store.select_mode("u1", "c1", "chat") == "work"
|
||||
assert await store.select_mode("u2", "c1", "chat") == "chat"
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
async def test_memory_and_events_are_user_scoped(settings) -> None:
|
||||
store = RuntimeStore(settings.database_url)
|
||||
await store.initialize()
|
||||
|
||||
Reference in New Issue
Block a user