From f21a58e75a55e9b7e5ff3b06eade5fc71e70a1ce Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Tue, 3 Nov 2026 22:04:34 +0800 Subject: [PATCH] [feat] agent: add DeepSeek Harness CLI and native execution traces Signed-off-by: Weiming Liao --- apps/api/src/opendirector_api/agent.py | 7 +- docs/architecture/agent-runtime.md | 2 + packages/core/pyproject.toml | 2 +- .../core/src/opendirector/agent/catalog.py | 3 + .../core/src/opendirector/agent/context.py | 2 +- .../core/src/opendirector/agent/deepseek.py | 184 ++++++++++++++++++ .../core/src/opendirector/agent/native.py | 41 ++-- .../core/src/opendirector/agent/runtime.py | 9 + .../opendirector/domain/creation/assets.py | 2 +- requirements.txt | 2 + src/backend/chat.ts | 8 +- src/canvas/AgentPicker.tsx | 12 +- src/canvas/agentPreferences.ts | 4 +- src/contracts/creation.schema.json | 6 +- src/contracts/generated.ts | 4 +- tests/agentPreferences.test.ts | 10 + tests/test_deepseek_engine.py | 86 ++++++++ tests/test_deepseek_runtime.py | 173 ++++++++++++++++ tests/test_runtime_readiness.py | 4 +- 19 files changed, 530 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/opendirector/agent/deepseek.py create mode 100644 tests/test_deepseek_engine.py create mode 100644 tests/test_deepseek_runtime.py diff --git a/apps/api/src/opendirector_api/agent.py b/apps/api/src/opendirector_api/agent.py index 092cf0ad..6b7063ea 100644 --- a/apps/api/src/opendirector_api/agent.py +++ b/apps/api/src/opendirector_api/agent.py @@ -34,7 +34,7 @@ class Draft(BaseModel): assets: list[dict[str, JsonValue]] = Field(default_factory=list, max_length=64) runtime: Runtime = 'default' runtimeModel: str | None = Field(default=None, max_length=200) - harnessModels: dict[Literal["opencode", "codex", "claude", "jiuwenswarm"], Annotated[str, Field(max_length=200)] | None] = Field(default_factory=dict) + harnessModels: dict[Literal["opencode", "codex", "claude", "jiuwenswarm", "deepseek"], Annotated[str, Field(max_length=200)] | None] = Field(default_factory=dict) thinking: Literal['auto', 'low', 'medium', 'high'] = 'auto' modelSelections: dict[str, str | None] = Field(default_factory=dict) skills: list[str] = Field(default_factory=list, max_length=1) @@ -195,8 +195,11 @@ async def runtime_status(project_id: UUID, runtime: Runtime): return {**item, 'status': await runtime_readiness(runtime)} @router.get('/runtimes/{runtime}/models') - async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm']): + async def runtime_models(project_id: UUID, runtime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm', 'deepseek'], selected: str | None = None): await asyncio.to_thread(conversations.path, project_id) + if runtime == 'deepseek': + from opendirector.agent.deepseek import model_choices as deepseek_models + return await deepseek_models(selected) return await model_choices(runtime) @router.get('/skills') diff --git a/docs/architecture/agent-runtime.md b/docs/architecture/agent-runtime.md index f833c172..1d0a19d5 100644 --- a/docs/architecture/agent-runtime.md +++ b/docs/architecture/agent-runtime.md @@ -13,6 +13,7 @@ Harness 指承担模型调用、工具执行和上下文管理的 Agent 执行 | Codex | PATH 中的 codex | 原生认证与模型目录;隔离启动前解析所选模型或默认模型 | | Claude Code | PATH 中的 claude | 原生认证、环境变量与初始化模型目录 | | JiuwenSwarm | PATH 中的 jiuwenswarm-process,通过 stdin/stdout JSONL 直接调用 | 本机 Jiuwen 模型目录、配置与环境变量 | +| DeepSeek Harness | PATH 中的 dsh,以 acp profile 运行标准 JSON-RPC | CLI 原生 profile、模型目录与认证 | Default 与 OpenCode CLI 分别配置。外部 CLI 不读取 OpenDirector 保存的模型凭证。进程继承服务端环境,包括 PATH 和第三方 API 配置。 @@ -60,5 +61,6 @@ Default 将模型配置中的输入模态传入 bundled OpenCode。外部 harnes | Codex | workspace-write 沙箱,开放 Shell 和业务工具 | | Claude Code | 明确允许 Skill、Bash、Read 和业务工具 | | JiuwenSwarm | 明确允许 skill_tool、list_skill、read_file、bash 和业务工具 | +| DeepSeek Harness | 沿用原生工具与权限;Skill 限于本轮快照,业务工具通过 MCP 接入,未承接的审批不自动批准 | 工具允许列表与操作系统沙箱提供不同约束。业务工具独立校验工程范围和输入;原生文件访问权限不授予直接改写工程存储的业务权限。 diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 8a3d5bf8..4317e5ed 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "opendirector-core" version = "0.1.0" requires-python = ">=3.11" -dependencies = ["filelock>=3.16,<4", "httpx>=0.27,<1", "anyio>=4,<5", "pydantic>=2,<3", "Pillow>=12,<14"] +dependencies = ["PyYAML>=6,<7", "filelock>=3.16,<4", "httpx>=0.27,<1", "anyio>=4,<5", "pydantic>=2,<3", "Pillow>=12,<14"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/core/src/opendirector/agent/catalog.py b/packages/core/src/opendirector/agent/catalog.py index 0d7a0320..bbd89083 100644 --- a/packages/core/src/opendirector/agent/catalog.py +++ b/packages/core/src/opendirector/agent/catalog.py @@ -50,6 +50,9 @@ async def opencode_fallback(binary: str, configured_providers: list[str]) -> str async def model_choices(runtime: str) -> list[dict]: + if runtime == 'deepseek': + from opendirector.agent.deepseek import model_choices as deepseek_models + return await deepseek_models() if runtime == 'jiuwenswarm': from opendirector.agent.jiuwenswarm import model_choices as swarm_models return await swarm_models() diff --git a/packages/core/src/opendirector/agent/context.py b/packages/core/src/opendirector/agent/context.py index 6dd19c41..214f6060 100644 --- a/packages/core/src/opendirector/agent/context.py +++ b/packages/core/src/opendirector/agent/context.py @@ -6,7 +6,7 @@ from pydantic import JsonValue -RuntimeId = Literal['default', 'opencode', 'codex', 'claude', 'jiuwenswarm'] +RuntimeId = Literal['default', 'opencode', 'codex', 'claude', 'jiuwenswarm', 'deepseek'] AgentEvent = dict[str, JsonValue] diff --git a/packages/core/src/opendirector/agent/deepseek.py b/packages/core/src/opendirector/agent/deepseek.py new file mode 100644 index 00000000..6ec297ef --- /dev/null +++ b/packages/core/src/opendirector/agent/deepseek.py @@ -0,0 +1,184 @@ +"""DeepSeek Harness CLI over standard ACP JSON-RPC; no Python SDK required.""" +import asyncio +from contextlib import suppress +import json +import os +from pathlib import Path +import shutil +import sys +from tempfile import TemporaryDirectory + +import yaml + +from opendirector.agent.process import terminate +from opendirector.agent.tools import ROOT +from opendirector.domain.projects import ProjectError + + +class Client: + def __init__(self, process, observe=None): + self.process = process + self.observe = observe + self.sequence = 0 + self.session = None + + async def write(self, record): + self.process.stdin.write((json.dumps(record, ensure_ascii=False) + '\n').encode()) + await self.process.stdin.drain() + + async def request(self, method, params): + self.sequence += 1 + identity = self.sequence + await self.write({'jsonrpc': '2.0', 'id': identity, 'method': method, 'params': params}) + while line := await self.process.stdout.readline(): + record = json.loads(line) + if not line.endswith(b'\n') or not isinstance(record, dict) or record.get('jsonrpc') != '2.0': + raise ProjectError('DeepSeek Harness ACP 协议不匹配。', 502) + if self.observe: + await self.observe(record) + if 'method' in record: + if 'id' not in record: + continue + if record['method'] == 'session/request_permission': + # Native policy requests never imply an unattended grant. + options = record['params']['options'] + rejected = next((item for item in options if item['kind'] == 'reject_once'), None) + outcome = {'outcome': 'selected', 'optionId': rejected['optionId']} if rejected else {'outcome': 'cancelled'} + response = {'jsonrpc': '2.0', 'id': record['id'], 'result': {'outcome': outcome}} + else: + response = {'jsonrpc': '2.0', 'id': record['id'], + 'error': {'code': -32601, 'message': 'Client method is not supported'}} + await self.write(response) + continue + if record.get('id') != identity: + raise ProjectError('DeepSeek Harness ACP 回复标识不匹配。', 502) + if 'error' in record: + raise ProjectError(f"DeepSeek Harness 请求失败({method}),请检查 CLI 模型与配置。", 502) + return record['result'] + raise ProjectError('DeepSeek Harness 在回复完成前退出。', 502) + + async def initialize(self, work, servers): + result = await self.request('initialize', {'protocolVersion': 1, + 'clientInfo': {'name': 'opendirector', 'version': '0.1.0'}, 'clientCapabilities': {}}) + if result['protocolVersion'] != 1: + raise ProjectError('DeepSeek Harness 不支持 ACP v1,请更新 CLI。', 503) + result = await self.request('session/new', {'cwd': str(work), 'mcpServers': servers}) + self.session = result['sessionId'] + return result.get('configOptions', []) + + async def close(self): + if self.session: + await self.request('session/close', {'sessionId': self.session}) + self.process.stdin.close() + await self.process.wait() + + +def arguments(private, skill_root=None): + binary = shutil.which('dsh') + if binary is None: + raise ProjectError('DeepSeek Harness 尚未安装,请安装 dsh CLI。', 503) + home = Path(os.environ.get('DSH_HOME', Path.home() / '.dsh')).expanduser() + desktop = home / 'profiles/desktop/cordis.patch.yml' + acp = home / 'profiles/acp/cordis.patch.yml' + try: + desktop_rows = (yaml.safe_load(desktop.read_text(encoding='utf-8')) or []) if desktop.is_file() else [] + acp_rows = (yaml.safe_load(acp.read_text(encoding='utf-8')) or []) if acp.is_file() else [] + except yaml.YAMLError as error: + raise ProjectError('无法读取 DeepSeek Harness 原生模型配置。', 503) from error + overridden = {row.get('id') for row in acp_rows if 'config' in row} + rows = [row for row in desktop_rows if row.get('id', '').startswith('llm-') + and row['id'] not in overridden and 'config' in row] + default = next((row['config'] for row in desktop_rows if row.get('id') == 'agent-default-model'), None) + if default and 'acp' not in overridden: + rows.append({'id': 'acp', 'config': {key: default[key] for key in ('provider', 'model')}}) + rows.append({'id': 'skill-filesystem', 'config': {'includeDefaultRoots': False, + 'customSkillDirs': [str(skill_root)] if skill_root else [], 'watch': False}}) + patch = private / 'deepseek.patch.json' + patch.write_text(json.dumps(rows, ensure_ascii=False), encoding='utf-8') + patch.chmod(0o600) + return [binary, '--profile', 'acp', '--patch', str(patch)] + + +def command(work, socket, token, bridge_env=None): + args = arguments(Path(socket).parent, work / '.agents/skills') + server = {'name': 'opendirector', 'command': sys.executable, + 'args': [str(ROOT / 'scripts/agent-mcp.py')], + 'env': [{'name': key, 'value': value} for key, value in + {**(bridge_env or {'OPENDIRECTOR_AGENT_SOCKET': socket}), 'OPENDIRECTOR_AGENT_TOKEN': token}.items()]} + return args, dict(os.environ), server + + +async def model_choices(selected=None): + with TemporaryDirectory(prefix='od-deepseek-query-') as temporary: + process = await asyncio.create_subprocess_exec(*arguments(Path(temporary)), cwd=temporary, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, start_new_session=True, limit=8 * 1024 * 1024) + client = Client(process) + try: + async with asyncio.timeout(30): + options = await client.initialize(Path(temporary), []) + default_value = next(item for item in options if item['id'] == 'model')['currentValue'] + if selected: + result = await client.request('session/set_config_option', {'sessionId': client.session, 'configId': 'model', 'value': selected}) + options = result['configOptions'] + model = next(item for item in options if item['id'] == 'model') + reasoning = next((item for item in options if item['id'] == 'reasoning_effort'), {}) + efforts = [item['value'] for item in reasoning.get('options', []) if item['value'] in {'low', 'medium', 'high'}] + rows = [] + for group in model['options']: + for item in group.get('options', [group]): + rows.append({'id': item['value'], 'label': item['name'], + 'efforts': efforts if item['value'] == model['currentValue'] else [], + 'isDefault': item['value'] == default_value}) + await client.close() + return rows + except (TimeoutError, OSError, ValueError, KeyError, StopIteration) as error: + raise ProjectError('DeepSeek Harness 模型查询失败,请检查 CLI 配置。', 503) from error + finally: + await terminate(process, group=True) + + +async def run(process, work, prompt, model, thinking, server, observe): + client = Client(process, observe) + try: + async with asyncio.timeout(30): + await client.initialize(work, [server]) + if model: + await client.request('session/set_config_option', {'sessionId': client.session, 'configId': 'model', 'value': model}) + if thinking != 'auto': + await client.request('session/set_config_option', {'sessionId': client.session, 'configId': 'reasoning_effort', 'value': thinking}) + result = await client.request('session/prompt', {'sessionId': client.session, + 'prompt': [{'type': 'text', 'text': prompt}]}) + if result['stopReason'] != 'end_turn': + raise ProjectError(f"DeepSeek Harness 执行未完成({result['stopReason']})。", 502) + finally: + async def cleanup(): + if client.session: + await client.write({'jsonrpc': '2.0', 'method': 'session/cancel', 'params': {'sessionId': client.session}}) + await client.close() + with suppress(TimeoutError, OSError, ProjectError, ValueError): + await asyncio.wait_for(cleanup(), 5) + await terminate(process, group=True) + + +def events(record, calls): + if record.get('method') != 'session/update': + return [] + update = record['params']['update'] + kind = update['sessionUpdate'] + if kind == 'agent_message_chunk' and update['content']['type'] == 'text': + return [{'type': 'delta', 'text': update['content']['text']}] + if kind not in {'tool_call', 'tool_call_update'}: + return [] + identity = update['toolCallId'] + previous = calls.get(identity, {}) + name = update.get('title', previous.get('name', 'tool')) + arguments = update.get('rawInput', previous.get('arguments', {})) + detail = json.dumps(update.get('content', []), ensure_ascii=False) + event = {'type': 'tool', 'id': 'deepseek:' + identity, 'name': name, + 'arguments': arguments, 'status': {'pending': 'running', 'in_progress': 'running', + 'completed': 'complete', 'failed': 'failed'}[update.get('status', 'in_progress')], + 'result': detail[:65536], 'resultTruncated': len(detail) > 65536} + calls[identity] = event + # Shared business activity is reported from authoritative gateway receipts. + return [] if any(name in {f'opendirector.{tool}', f'mcp__opendirector__{tool}'} for tool in ('read_project', 'read', 'command_schema', 'execute_command')) else [event] diff --git a/packages/core/src/opendirector/agent/native.py b/packages/core/src/opendirector/agent/native.py index 28734471..db89c490 100644 --- a/packages/core/src/opendirector/agent/native.py +++ b/packages/core/src/opendirector/agent/native.py @@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory from opendirector.agent.model_gateway import ModelGateway +from opendirector.agent import deepseek from opendirector.agent.context import RunContext from opendirector.agent.gateway import ToolGateway from opendirector.agent.process import terminate @@ -25,7 +26,7 @@ def executable(runtime: str) -> str | None: if path.is_file(): return str(path) return None - return shutil.which(runtime) + return shutil.which('dsh' if runtime == 'deepseek' else runtime) def command(runtime: str, work: Path, socket: str, token: str, model: str | None, @@ -174,9 +175,13 @@ async def connection(reader, writer): model_server = await asyncio.start_server(model.connect, '127.0.0.1', 0) if model else None try: model_url = f'http://127.0.0.1:{model_server.sockets[0].getsockname()[1]}' if model_server else '' - args, env = command(run.runtime, work, endpoint, token, - config['modelId'] if config else runtime_model, model_url, catalog, bridge_env=bridge_env, - **({'input_modalities': config.get('inputModalities', ['text'])} if run.runtime == 'default' else {})) + deepseek_server = None + if runtime == 'deepseek': + args, env, deepseek_server = deepseek.command(work, endpoint, token, bridge_env) + else: + args, env = command(run.runtime, work, endpoint, token, + config['modelId'] if config else runtime_model, model_url, catalog, bridge_env=bridge_env, + **({'input_modalities': config.get('inputModalities', ['text'])} if run.runtime == 'default' else {})) if thinking != 'auto' and runtime == 'codex': args += ['-c', f'model_reasoning_effort={json.dumps(thinking)}'] elif thinking != 'auto' and runtime == 'claude': @@ -193,6 +198,8 @@ async def connection(reader, writer): prompt = f'${name}\n' + prompt elif runtime == 'claude': prompt = f'/opendirector:{name}\n' + prompt + elif runtime == 'deepseek': + prompt = f'Use the native skill tool to load {name} from {work / ".agents/skills"} before proceeding.\n' + prompt else: prompt = f'Use the native skill tool to load {name} before proceeding.\n' + prompt process = await asyncio.create_subprocess_exec(*args, cwd=work, env=env, @@ -204,14 +211,20 @@ async def drain_stderr(): stderr_tail.extend(chunk) del stderr_tail[:-8192] stderr_task = asyncio.create_task(drain_stderr()) - process.stdin.write(prompt.encode() + b'\n') - await process.stdin.drain() - process.stdin.close() + if runtime != 'deepseek': + process.stdin.write(prompt.encode() + b'\n') + await process.stdin.drain() + process.stdin.close() async def output(): try: - while line := await process.stdout.readline(): - raw = json.loads(line) - await queue.put({'native': raw}) + if runtime == 'deepseek': + async def observe(raw): + await queue.put({'native': raw}) + await deepseek.run(process, work, SYSTEM + '\n' + prompt, runtime_model, thinking, deepseek_server, observe) + else: + while line := await process.stdout.readline(): + raw = json.loads(line) + await queue.put({'native': raw}) if await process.wait(): await stderr_task diagnostic = stderr_tail.decode('utf-8', errors='replace').replace(token, '[redacted]') @@ -224,12 +237,13 @@ async def output(): pump = asyncio.create_task(output()) run_dir = directory / 'agent-runs' run_dir.mkdir(exist_ok=True) + native_calls = {} with (run_dir / f'{turn_id}.{runtime}.jsonl').open('a', encoding='utf-8') as trace: while (event := await queue.get()) is not None: trace.write(json.dumps(event, ensure_ascii=False) + '\n') trace.flush() if 'native' in event: - for item in text_events(runtime, event['native']): + for item in (deepseek.events(event['native'], native_calls) if runtime == 'deepseek' else text_events(runtime, event['native'])): yield item else: yield event @@ -246,6 +260,11 @@ async def output(): for task in model.active: task.cancel() await asyncio.gather(*model.active, return_exceptions=True) + if runtime == 'deepseek' and pump and not pump.done(): + while not queue.empty(): + queue.get_nowait() + pump.cancel() + await asyncio.gather(pump, return_exceptions=True) if process is not None: await terminate(process, group=True) while not queue.empty(): diff --git a/packages/core/src/opendirector/agent/runtime.py b/packages/core/src/opendirector/agent/runtime.py index 814bd805..18d2541a 100644 --- a/packages/core/src/opendirector/agent/runtime.py +++ b/packages/core/src/opendirector/agent/runtime.py @@ -30,6 +30,7 @@ class RuntimeSpec: 'default': RuntimeSpec(id='default', label='Default', group='default', modelSource='settings', modelOverride=False, engine='default'), 'opencode': RuntimeSpec(id='opencode', label='OpenCode', group='harness', modelSource='cli', modelOverride=True, engine='opencode'), 'jiuwenswarm': RuntimeSpec(id='jiuwenswarm', label='JiuwenSwarm', group='harness', modelSource='cli', modelOverride=True, engine='jiuwenswarm'), + 'deepseek': RuntimeSpec(id='deepseek', label='DeepSeek Harness', group='harness', modelSource='cli', modelOverride=True, engine='deepseek'), 'codex': RuntimeSpec(id='codex', label='Codex', group='harness', modelSource='cli', modelOverride=True, engine='codex'), 'claude': RuntimeSpec(id='claude', label='Claude Code', group='harness', modelSource='cli', modelOverride=True, engine='claude'), } @@ -55,6 +56,14 @@ def runtime_catalog() -> list[dict]: async def runtime_readiness(engine: str) -> str: + if engine == 'deepseek': + if native.executable(engine) is None: + return 'not_installed' + from opendirector.agent.deepseek import model_choices + try: + return 'ready' if await model_choices() else 'needs_configuration' + except ProjectError: + return 'unknown' if engine == 'jiuwenswarm': return await jiuwenswarm.readiness() executable = native.executable(engine) diff --git a/packages/core/src/opendirector/domain/creation/assets.py b/packages/core/src/opendirector/domain/creation/assets.py index 70e2ee5b..433b93eb 100644 --- a/packages/core/src/opendirector/domain/creation/assets.py +++ b/packages/core/src/opendirector/domain/creation/assets.py @@ -62,7 +62,7 @@ class AssetProvenance(Contract): sources: tuple[AssetRef, ...] = () inputFingerprint: str | None = Field(default=None, pattern=r'^[0-9a-f]{64}$') agentRunId: UUID | None = None - agentRuntime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm'] | None = None + agentRuntime: Literal['opencode', 'codex', 'claude', 'jiuwenswarm', 'deepseek'] | None = None modelSnapshot: ModelSnapshot | None = None prompt: str | None = Field(default=None, max_length=20000) diff --git a/requirements.txt b/requirements.txt index ee4f7dc2..9fde5761 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,8 @@ pygments==2.21.0 # via pytest pytest==9.1.1 # via opendirector-api +pyyaml==6.0.3 + # via opendirector-core starlette==1.8.0 # via fastapi typing-extensions==4.16.0 diff --git a/src/backend/chat.ts b/src/backend/chat.ts index c53015ae..0f84961a 100644 --- a/src/backend/chat.ts +++ b/src/backend/chat.ts @@ -6,7 +6,8 @@ export type AgentRuntime = | "opencode" | "codex" | "claude" - | "jiuwenswarm"; + | "jiuwenswarm" + | "deepseek"; export interface RuntimeInfo { id: AgentRuntime; label: string; @@ -194,5 +195,6 @@ export interface RuntimeModel { label: string; efforts: string[]; } -export const runtimeModels = (projectId: string, runtime: AgentRuntime) => - request(path(projectId) + `/runtimes/${runtime}/models`); +export const runtimeModels = (projectId: string, runtime: AgentRuntime, selected?: string | null) => + request(path(projectId) + `/runtimes/${runtime}/models` + + (selected ? `?selected=${encodeURIComponent(selected)}` : "")); diff --git a/src/canvas/AgentPicker.tsx b/src/canvas/AgentPicker.tsx index d18d43d3..e9658492 100644 --- a/src/canvas/AgentPicker.tsx +++ b/src/canvas/AgentPicker.tsx @@ -97,7 +97,9 @@ export function AgentPicker({ ? (configuredModel(item.id)?.modelId ?? "选择模型") : modelLabel(item.id); const setupCommand = - setup === "jiuwenswarm" + setup === "deepseek" + ? "dsh --profile acp" + : setup === "jiuwenswarm" ? "jiuwenswarm-init" : setup === "codex" ? "codex login" @@ -105,7 +107,9 @@ export function AgentPicker({ ? "claude auth login" : "opencode"; const setupLink = - setup === "jiuwenswarm" + setup === "deepseek" + ? "https://github.com/deepseek-ai/deepseek-harness" + : setup === "jiuwenswarm" ? "https://github.com/openJiuwen-ai/jiuwenswarm" : setup === "codex" ? "https://developers.openai.com/codex/cli/" @@ -205,7 +209,7 @@ export function AgentPicker({ setLoading(true); setChoices([]); setModelError(""); - runtimeModels(projectId, editing) + runtimeModels(projectId, editing, editing === "deepseek" ? selectedModel(editing) : null) .then((items) => { if (active) { setChoices(items); @@ -221,7 +225,7 @@ export function AgentPicker({ return () => { active = false; }; - }, [editing, editingSpec?.modelSource, projectId, checking]); + }, [editing, editingSpec?.modelSource, projectId, checking, value.harnessModels]); useEffect(() => { if (!open || disabled) return; const outside = (event: Event) => { diff --git a/src/canvas/agentPreferences.ts b/src/canvas/agentPreferences.ts index 7425dbaf..c4d4fd63 100644 --- a/src/canvas/agentPreferences.ts +++ b/src/canvas/agentPreferences.ts @@ -9,13 +9,13 @@ function parse(value: string | null): Selection | null { if (!value) return null; const data: unknown = JSON.parse(value); if (!data || typeof data !== "object" || !("runtime" in data) || - !(data.runtime === "default" || data.runtime === "codex" || data.runtime === "claude" || data.runtime === "opencode" || data.runtime === "jiuwenswarm") || + !(data.runtime === "default" || data.runtime === "codex" || data.runtime === "claude" || data.runtime === "opencode" || data.runtime === "jiuwenswarm" || data.runtime === "deepseek") || !("runtimeModel" in data) || !(data.runtimeModel === null || typeof data.runtimeModel === "string") || !("agent" in data) || !(data.agent === null || typeof data.agent === "string")) return null; const harnessModels: Selection["harnessModels"] = {}; if ("harnessModels" in data && data.harnessModels && typeof data.harnessModels === "object") { for (const [key, model] of Object.entries(data.harnessModels)) { - if ((key === "codex" || key === "claude" || key === "opencode" || key === "jiuwenswarm") && (model === null || typeof model === "string")) { + if ((key === "codex" || key === "claude" || key === "opencode" || key === "jiuwenswarm" || key === "deepseek") && (model === null || typeof model === "string")) { harnessModels[key] = model; } } diff --git a/src/contracts/creation.schema.json b/src/contracts/creation.schema.json index fbe61d7d..d7160277 100644 --- a/src/contracts/creation.schema.json +++ b/src/contracts/creation.schema.json @@ -531,7 +531,8 @@ "opencode", "codex", "claude", - "jiuwenswarm" + "jiuwenswarm", + "deepseek" ], "type": "string" }, @@ -12586,7 +12587,8 @@ "opencode", "codex", "claude", - "jiuwenswarm" + "jiuwenswarm", + "deepseek" ], "type": "string" }, diff --git a/src/contracts/generated.ts b/src/contracts/generated.ts index 1caafcfb..87553b4f 100644 --- a/src/contracts/generated.ts +++ b/src/contracts/generated.ts @@ -64,7 +64,7 @@ export interface AssetProvenanceInput extends ContractInput { readonly sources?: ReadonlyArray; readonly inputFingerprint?: string | null; readonly agentRunId?: string | null; - readonly agentRuntime?: "opencode" | "codex" | "claude" | "jiuwenswarm" | null; + readonly agentRuntime?: "opencode" | "codex" | "claude" | "jiuwenswarm" | "deepseek" | null; readonly modelSnapshot?: ModelSnapshotInput | null; readonly prompt?: string | null; } @@ -1438,7 +1438,7 @@ export interface AssetProvenance extends Contract { readonly sources: ReadonlyArray; readonly inputFingerprint: string | null; readonly agentRunId: string | null; - readonly agentRuntime: "opencode" | "codex" | "claude" | "jiuwenswarm" | null; + readonly agentRuntime: "opencode" | "codex" | "claude" | "jiuwenswarm" | "deepseek" | null; readonly modelSnapshot: ModelSnapshot | null; readonly prompt: string | null; } diff --git a/tests/agentPreferences.test.ts b/tests/agentPreferences.test.ts index ae6a82ba..95564ab3 100644 --- a/tests/agentPreferences.test.ts +++ b/tests/agentPreferences.test.ts @@ -63,3 +63,13 @@ test("JiuwenSwarm model selection survives refresh without changing other harnes assert.equal(restored.runtimeModel, "local-model#0"); assert.deepEqual(restored.harnessModels, value.harnessModels); }); + + +test("DeepSeek Harness selection and native model survive refresh", () => { + const local = storage(); + const value: Conversation = {...draft(), runtime: "deepseek", runtimeModel: "route/model", + harnessModels: {deepseek: "route/model", codex: "gpt-model"}}; + rememberAgentSelection("project", value, local); + assert.deepEqual(restoreAgentSelection("project", draft(), local).harnessModels, value.harnessModels); + assert.equal(restoreAgentSelection("project", draft(), local).runtime, "deepseek"); +}); diff --git a/tests/test_deepseek_engine.py b/tests/test_deepseek_engine.py new file mode 100644 index 00000000..3f5b8bcb --- /dev/null +++ b/tests/test_deepseek_engine.py @@ -0,0 +1,86 @@ +"""Opt-in installed dsh CLI with a local simulated model, never a paid endpoint.""" +import json +import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from uuid import uuid4 + +from fastapi.testclient import TestClient +import pytest + +from opendirector.infrastructure.local_projects import LocalProjects +from opendirector_api.main import create_app + + +@pytest.mark.skipif(os.getenv('DEEPSEEK_ENGINE_TEST') != '1', reason='requires installed dsh CLI') +def test_real_cli_native_skill_and_shared_project_write(tmp_path, monkeypatch): + calls, failures = [], [] + steps = [('skill', {'name': 'example'}), + ('bash', {'command': "printf 'native bash' > native-check.txt", 'description':'Write a native execution marker'}), + ('mcp__opendirector__read_project', {}), + ('mcp__opendirector__execute_command', {'type':'asset.writeText','payload':{'name':'deepseek.md','text':'Real CLI, local model'}})] + class Model(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + def do_POST(self): + request=json.loads(self.rfile.read(int(self.headers['Content-Length']))) + index=len(calls) + calls.append(request) + if index < len(steps): + name, arguments=steps[index] + names={tool['name'] for tool in request.get('tools',[])} + if name not in names: + failures.append((name,sorted(names))) + block={'type':'tool_use','id':f'fixture-{index}','name':name,'input':{}} + delta={'type':'input_json_delta','partial_json':json.dumps(arguments)} + reason='tool_use' + else: + block={'type':'text','text':''} + delta={'type':'text_delta','text':'Real CLI finished.'} + reason='end_turn' + events=[ + {'type':'message_start','message':{'id':f'message-{index}','type':'message','role':'assistant', + 'model':'deepseek-v4-flash','content':[],'usage':{'input_tokens':10,'output_tokens':0}}}, + {'type':'content_block_start','index':0,'content_block':block}, + {'type':'content_block_delta','index':0,'delta':delta}, + {'type':'content_block_stop','index':0}, + {'type':'message_delta','delta':{'stop_reason':reason,'stop_sequence':None},'usage':{'output_tokens':5}}, + {'type':'message_stop'}, + ] + self.send_response(200) + self.send_header('Content-Type','text/event-stream');self.end_headers() + for event in events: + self.wfile.write(('event: '+event['type']+'\ndata: '+json.dumps(event)+'\n\n').encode()) + self.wfile.flush() + server=ThreadingHTTPServer(('127.0.0.1',0),Model) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + monkeypatch.setenv('DSH_HOME',str(tmp_path/'dsh-home')) + monkeypatch.setenv('DEEPSEEK_API_KEY','fixture-not-a-secret') + monkeypatch.setenv('DEEPSEEK_BASE_URL',f'http://127.0.0.1:{server.server_port}') + monkeypatch.setenv('NO_PROXY','localhost,127.0.0.1,::1') + monkeypatch.setenv('no_proxy','localhost,127.0.0.1,::1') + skill=tmp_path/'.opendirector/skills/example' + skill.mkdir(parents=True) + (skill/'SKILL.md').write_text('---\nname: example\ndescription: Fixture skill\n---\nNATIVE_DEEPSEEK_SKILL_MARKER') + foreign=tmp_path/'dsh-home/skills/unapproved' + foreign.mkdir(parents=True) + (foreign/'SKILL.md').write_text('---\nname: unapproved\ndescription: UNAPPROVED_SKILL_MARKER\n---\nNot part of the project') + local=LocalProjects(tmp_path) + try: + with TestClient(create_app(local),headers={'X-OpenDirector-Request':'1','X-OpenDirector-Session':str(uuid4())}) as client: + pid,turn=str(uuid4()),str(uuid4()) + path='/api/creation/projects/'+pid + assert client.post(path+'/commands',json=dict(type='project.create',projectId=pid,commandId=str(uuid4()),payload={'name':'DeepSeek real CLI'})).status_code==200 + assert client.post(path+'/open').status_code==200 + response=client.post(path+'/assistant/turns',json=dict(id=turn,runtime='deepseek',text='Load the example skill and save a text asset.',skills=['example'])) + events=[json.loads(line[6:]) for line in response.text.splitlines() if line.startswith('data: ')] + assert not failures, failures + assert events[-1]['error'] is None, events[-1] + assert events[-1]['conversation']['messages'][-1]['text']=='Real CLI finished.' + assets=client.get(path).json()['assets'] + assert assets[0]['provenance']['agentRuntime']=='deepseek' + assert (local.root/'DeepSeek real CLI/agent-runs'/turn/'work/native-check.txt').read_text()=='native bash' + assert 'NATIVE_DEEPSEEK_SKILL_MARKER' in json.dumps(calls[1]['messages']) + assert 'UNAPPROVED_SKILL_MARKER' not in json.dumps(calls) + finally: + server.shutdown();server.server_close();thread.join(timeout=5) diff --git a/tests/test_deepseek_runtime.py b/tests/test_deepseek_runtime.py new file mode 100644 index 00000000..82c3b638 --- /dev/null +++ b/tests/test_deepseek_runtime.py @@ -0,0 +1,173 @@ +"""Drive real CLI pipes and the existing MCP gateway with an ACP fixture.""" +import asyncio +from contextlib import aclosing +import json +import os +from pathlib import Path +import sys +from uuid import uuid4 + +from fastapi.testclient import TestClient +import pytest + +from opendirector.agent import deepseek +from opendirector.agent.context import RunContext +from opendirector.agent.runtime import AgentRuntimes +from opendirector.domain.projects import ProjectError +from opendirector_api.main import create_app +from opendirector.infrastructure.local_projects import LocalProjects + +FAKE = r''' +import json, os, subprocess, sys +from pathlib import Path +assert sys.argv[1:3] == ['--profile','acp'] +mode = os.getenv('DEEPSEEK_TEST', 'success') +sid = 'owned-session' +def write(record): + print(json.dumps(dict(jsonrpc='2.0', **record)), flush=True) +def result(req, value): write(dict(id=req['id'], result=value)) +def read(): return json.loads(sys.stdin.readline()) +req=read(); assert req['method']=='initialize' +result(req, dict(protocolVersion=0 if mode=='bad_version' else 1)) +req=read(); assert req['method']=='session/new' +servers=req['params']['mcpServers'] +model=dict(id='model',currentValue='route/default',options=[dict(group='route',options=[dict(value='route/default',name='Default model')])]) +if mode=='efforts': + model['options'][0]['options'].append(dict(value='route/other',name='Other model')) +reasoning=dict(id='reasoning_effort',options=[dict(value='low'),dict(value='high')]) +result(req,dict(sessionId=sid,configOptions=[model]+([reasoning] if mode=='efforts' else []))) +for line in sys.stdin: + req=json.loads(line) + if req['method']=='session/set_config_option': + assert req['params']['value'] in {'route/default','route/other'} + model['currentValue']=req['params']['value'] + result(req, {'configOptions':[model]+([reasoning] if mode=='efforts' else [])}) + elif req['method']=='session/close': + result(req,{}); break + elif req['method']=='session/cancel': + Path(os.environ['DEEPSEEK_CANCELLED']).write_text('cancelled') if 'DEEPSEEK_CANCELLED' in os.environ else None + elif req['method']=='session/prompt': + text=req['params']['prompt'][0]['text'] + assert 'Use OpenDirector' in text and str(Path.cwd()) in text + patch=json.loads(Path(sys.argv[sys.argv.index('--patch')+1]).read_text()) + assert next(row for row in patch if row['id']=='skill-filesystem')['config']['includeDefaultRoots'] is False + if mode!='wait': assert list(Path('.agents/skills').glob('*/SKILL.md')) + if mode=='wait': + write(dict(method='session/update',params=dict(sessionId=sid,update=dict(sessionUpdate='agent_message_chunk',content=dict(type='text',text='Waiting'))))) + continue + if mode=='permission': + write(dict(id='approval-1',method='session/request_permission',params=dict(sessionId=sid,options=[dict(optionId='allow',kind='allow_once'),dict(optionId='reject',kind='reject_once')]))) + answer=read() + assert answer['result']['outcome']==dict(outcome='selected',optionId='reject') + server=servers[0] + env={**os.environ,**{item['name']:item['value'] for item in server['env']}} + bridge=subprocess.Popen([server['command'],*server['args']],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True,env=env) + def rpc(value): + bridge.stdin.write(json.dumps(value)+'\n');bridge.stdin.flush() + response=json.loads(bridge.stdout.readline());assert 'error' not in response,response + return response['result'] + rpc({'id':1,'method':'initialize'}) + assert len(rpc({'id':2,'method':'tools/list'})['tools'])==4 + outcome=rpc({'id':3,'method':'tools/call','params':{'name':'execute_command','arguments':{'type':'asset.writeText','payload':{'name':'deepseek.md','text':'CLI saved'}}}}) + assert not outcome['isError'],outcome + bridge.stdin.close();bridge.wait() + write(dict(method='session/update',params=dict(sessionId=sid,update=dict(sessionUpdate='tool_call',toolCallId='native-read',title='read',rawInput={'path':'file'},status='in_progress')))) + write(dict(method='session/update',params=dict(sessionId=sid,update=dict(sessionUpdate='tool_call_update',toolCallId='native-read',status='completed',content=[])))) + write(dict(method='session/update',params=dict(sessionId=sid,update=dict(sessionUpdate='agent_message_chunk',content=dict(type='text',text='Saved.'))))) + result(req,dict(stopReason='max_tokens' if mode=='incomplete' else 'end_turn')) +''' + + +@pytest.fixture +def cli(tmp_path, monkeypatch): + binary = tmp_path / 'dsh' + binary.write_text(f'#!{sys.executable}\n' + FAKE) + binary.chmod(0o755) + monkeypatch.setenv('PATH', str(tmp_path) + os.pathsep + os.environ['PATH']) + skill = tmp_path / '.opendirector/skills/example' + skill.mkdir(parents=True) + (skill / 'SKILL.md').write_text('---\nname: example\ndescription: Test\n---\nTest skill') + return binary + + +def test_catalog_from_acp_config(cli): + assert asyncio.run(deepseek.model_choices()) == [dict(id='route/default',label='Default model',efforts=[],isDefault=True)] + + +@pytest.mark.parametrize('mode', ['success', 'incomplete', 'permission']) +def test_acp_shared_write_and_native_trace(cli, tmp_path, monkeypatch, mode): + monkeypatch.setenv('DEEPSEEK_TEST', mode) + local = LocalProjects(tmp_path) + with TestClient(create_app(local), headers={'X-OpenDirector-Request':'1','X-OpenDirector-Session':str(uuid4())}) as client: + pid, turn = str(uuid4()), str(uuid4()) + path = '/api/creation/projects/' + pid + assert client.post(path+'/commands',json=dict(type='project.create',projectId=pid,commandId=str(uuid4()),payload={'name':'DeepSeek'})).status_code == 200 + assert client.post(path+'/open').status_code == 200 + response = client.post(path+'/assistant/turns',json=dict(id=turn,runtime='deepseek',runtimeModel='route/default',text='Save',skills=['example'])) + events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith('data: ')] + assert (events[-1]['error'] is None) == (mode != 'incomplete'), events + message = events[-1]['conversation']['messages'][-1] + assert message['text'] == 'Saved.' + assert any(tool['name']=='execute_command' and tool['status']=='complete' for tool in message['tools']) + assert any(tool['name']=='read' and tool['status']=='complete' for tool in message['tools']) + snapshot=client.get(path).json() + assert snapshot['assets'][0]['provenance']['agentRuntime']=='deepseek' + trace=next(local.root.rglob('*.deepseek.jsonl')).read_text() + assert 'owned-session' in trace and 'session/update' in trace + assert 'OPENDIRECTOR_AGENT_TOKEN' not in trace + + +def test_cancel_acp_before_collecting_child(cli, tmp_path, monkeypatch): + monkeypatch.setenv('DEEPSEEK_TEST','wait') + cancelled=tmp_path/'cancelled' + monkeypatch.setenv('DEEPSEEK_CANCELLED',str(cancelled)) + run=RunContext(runtime='deepseek',project_id=uuid4(),turn_id=uuid4(),session='test',directory=tmp_path, + config=None,messages=[],selected_skills=[],skills=[]) + async def consume(): + runtimes=AgentRuntimes(None,None,None) + async with aclosing(runtimes.stream(run)) as stream: + async for event in stream: + assert event['text']=='Waiting' + break + async def check(): + await asyncio.wait_for(consume(),10) + asyncio.run(check()) + assert cancelled.read_text()=='cancelled' + + +def test_rejects_incompatible_acp_version(cli, monkeypatch): + monkeypatch.setenv('DEEPSEEK_TEST', 'bad_version') + with pytest.raises(ProjectError, match='ACP v1'): + asyncio.run(deepseek.model_choices()) + + +def test_native_desktop_model_overlay_is_private_and_respects_acp_overrides(tmp_path, monkeypatch): + home = tmp_path / 'home' + desktop = home / 'profiles/desktop/cordis.patch.yml' + desktop.parent.mkdir(parents=True) + native = [{'id':'llm-pi-ai','config':{'providers':[{'id':'kimi-coding'}]}}, + {'id':'agent-default-model','config':{'provider':'deepseek-official','model':'deepseek-flash'}}, + {'id':'ui-theme','config':{'preference':'dark'}}] + desktop.write_text(json.dumps(native)) + acp = home / 'profiles/acp/cordis.patch.yml' + acp.parent.mkdir(parents=True) + acp.write_text('[]') + private = tmp_path / 'private' + private.mkdir() + monkeypatch.setenv('DSH_HOME', str(home)) + monkeypatch.setattr(deepseek.shutil,'which',lambda _: '/installed/dsh') + args = deepseek.arguments(private) + patch = json.loads(Path(args[-1]).read_text()) + assert patch[:2] == [native[0], {'id':'acp','config':{'provider':'deepseek-official','model':'deepseek-flash'}}] + assert all(row['id'] != 'ui-theme' for row in patch) + assert json.loads(desktop.read_text()) == native + acp.write_text(json.dumps([{'id':'llm-pi-ai','config':{}},{'id':'acp','config':{'provider':'other','model':'custom'}}])) + args = deepseek.arguments(private) + assert [row['id'] for row in json.loads(Path(args[-1]).read_text())] == ['skill-filesystem'] + + +def test_selected_model_efforts_keep_native_default_identity(cli, monkeypatch): + monkeypatch.setenv('DEEPSEEK_TEST', 'efforts') + rows = asyncio.run(deepseek.model_choices('route/other')) + assert rows[0]['isDefault'] is True and rows[0]['efforts'] == [] + assert rows[1]['isDefault'] is False and rows[1]['efforts'] == ['low', 'high'] diff --git a/tests/test_runtime_readiness.py b/tests/test_runtime_readiness.py index d12b0912..2fb244d6 100644 --- a/tests/test_runtime_readiness.py +++ b/tests/test_runtime_readiness.py @@ -35,8 +35,8 @@ async def probe(engine): monkeypatch.setattr(runtime, 'runtime_readiness', probe) result = asyncio.run(runtime.configured_runtimes()) - assert calls == ['default', 'opencode', 'jiuwenswarm', 'codex', 'claude'] - assert [item['status'] for item in result] == ['ready', 'ready', 'ready', 'ready', 'needs_login'] + assert calls == ['default', 'opencode', 'jiuwenswarm', 'deepseek', 'codex', 'claude'] + assert [item['status'] for item in result] == ['ready', 'ready', 'ready', 'ready', 'ready', 'needs_login'] def test_opencode_binary_sources(monkeypatch, tmp_path):