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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
310 changes: 310 additions & 0 deletions apps/api/src/opendirector_api/agent.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/api/src/opendirector_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from opendirector.integrations.model_capabilities import read_service_models, read_video_capabilities
from opendirector.integrations.chat_completions import ChatCompletions, chat_payload
from opendirector_api.creation import creation_routes
from opendirector_api.agent import agent_routes
from opendirector_api.library import library_routes
from opendirector_api.models import (ModelConfigInput, ModelConfigPublic, AgentModelTest,
ModelCapabilityRequest, ModelDiscoveryRequest, ModelBatchInput,
Expand Down Expand Up @@ -55,6 +56,7 @@ async def lifespan(app):

app = FastAPI(title="OpenDirector Local API", version="0.1.0", lifespan=lifespan)
app.include_router(creation_routes(creation_projects, creation_generation))
app.include_router(agent_routes(creation_projects, creation_generation, model_configs, chat_provider))
app.include_router(library_routes(creation_projects))

@app.exception_handler(RequestValidationError)
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ docs/
8. **按明确文件暂存。** 审阅实际差异后加入所需文档,避免通过暂存整个目录带入本地历史资料。

文档变更至少检查相对链接、代码与命令路径、格式和跟踪范围。纯文档调整无需重复运行与其无关的模型、媒体或浏览器测试。

- [Agent runtime](architecture/agent-runtime.md)
13 changes: 13 additions & 0 deletions docs/architecture/agent-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Agent runtime

Default runs the pinned bundled OpenCode process. OpenDirector supplies its configured chat model, declared input modalities and a turn-scoped MCP gateway. OpenCode owns its loop, native file and shell tools, and Skill loading.

Each turn writes intermediate files into `agent-runs/<turn-id>/work`; source assets remain at their original absolute paths. The process inherits the server PATH, so installed FFmpeg and ffprobe can be discovered with `command -v`. These programs must be installed before starting the server.

Project changes go through `read_project`, `command_schema` and `execute_command`, sharing validation and receipts with the UI. Publish each newly created file with `artifact.publish`, then use `canvas.show` to display it. A timeline of source ranges does not create independent clips.

Conversation drafts and messages persist separately from project objects. Only one reply per project is admitted; stop and stream disconnect terminate the process group, drain accepted business operations and save the partial reply. Native events and business-tool events are recorded in `agent-runs/<turn-id>.opencode.jsonl`.

Image reading requires both a compatible model/API and enabled image input in model settings. Native tool availability is not proof that a model can understand a video; inspect actual output. ComfyUI execution and character-replacement quality are outside this change. External CLI selection and the richer Skill and preview UI follow separately.

Validation covers the real bundled OpenCode process with a simulated model transport, shared MCP calls, cancellation, persisted conversation state, and actual FFmpeg outputs published through business commands. Simulated model tests do not establish real model planning or visual quality.
187 changes: 187 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"mediabunny": "1.62.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"three": "0.186.1"
"three": "0.186.1",
"opencode-ai": "1.19.5"
},
"devDependencies": {
"@types/node": "22.20.5",
Expand All @@ -31,6 +32,7 @@
"vite": "6.4.4"
},
"allowScripts": {
"esbuild@0.25.12": true
"esbuild@0.25.12": true,
"opencode-ai@1.19.5": true
}
}
25 changes: 25 additions & 0 deletions packages/core/src/opendirector/agent/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Shared input contract for SDK, CLI and service-backed runtimes."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from uuid import UUID

from pydantic import JsonValue

RuntimeId = Literal['default', 'opencode', 'codex', 'claude']
AgentEvent = dict[str, JsonValue]


@dataclass(kw_only=True)
class RunContext:
runtime: RuntimeId
project_id: UUID
turn_id: UUID
session: str
directory: Path
config: dict[str, JsonValue] | None
messages: list[dict[str, JsonValue]]
selected_skills: list[str]
thinking: str = 'auto'
runtime_model: str | None = None
skills: list[dict[str, str]] = field(default_factory=list)
79 changes: 79 additions & 0 deletions packages/core/src/opendirector/agent/gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""One turn's tool admission, replay and receipts, independent of transport."""
import asyncio
from copy import deepcopy
from dataclasses import dataclass
import json
from pathlib import Path

from pydantic import JsonValue, TypeAdapter, ValidationError

from opendirector.agent.context import AgentEvent, RunContext
from opendirector.agent.tools import AgentTools, tools
from opendirector.domain.projects import ProjectError


@dataclass(frozen=True, kw_only=True)
class ToolResult:
value: JsonValue
error: str | None = None

def event(self, name: str) -> AgentEvent:
return {'type': 'tool', 'name': name,
'status': 'failed' if self.error else 'complete',
**({'error': self.error} if self.error else {})}


class ToolGateway:
def __init__(self, business: AgentTools, run: RunContext):
self.business = business
self.run = run
self.roots: list[Path] = []
self.closed = False
self.calls: dict[str, tuple[str, asyncio.Task[ToolResult]]] = {}
self.lock = asyncio.Lock()
self.execution_lock = asyncio.Lock()
self.names = {item['function']['name'] for item in tools()}

async def call(self, call_id: str, name: str, arguments: dict[str, JsonValue]) -> ToolResult:
identity = json.dumps([name, arguments], ensure_ascii=False, sort_keys=True)
async with self.lock:
if self.closed:
return ToolResult(value={'error': '本轮工具入口已关闭。'}, error='本轮工具入口已关闭。')
previous = self.calls.get(call_id)
if previous is not None:
if previous[0] != identity:
return ToolResult(value={'error': '工具调用标识被用于不同输入。'}, error='工具调用标识被用于不同输入。')
task = previous[1]
else:
task = asyncio.create_task(self.execute(call_id, name, deepcopy(arguments)))
self.calls[call_id] = (identity, task)
# Transport cancellation must not discard an accepted write or its receipt.
return await asyncio.shield(task)

async def execute(self, call_id: str, name: str, arguments: dict[str, JsonValue]) -> ToolResult:
async with self.execution_lock:
try:
if self.closed:
raise ProjectError('本轮工具入口已关闭。', 409)
if not call_id or len(call_id) > 512 or name not in self.names:
raise ProjectError('工具调用标识或名称无效。')
arguments = TypeAdapter(dict[str, JsonValue]).validate_python(arguments, strict=True)
schema = next(item['function']['parameters'] for item in tools() if item['function']['name'] == name)
if set(arguments) - set(schema['properties']) or set(schema.get('required', [])) - set(arguments):
raise ProjectError('工具参数缺失或包含未定义字段。')
value = await self.business.execute(self.run.project_id, self.run.turn_id, self.run.session,
call_id, name, arguments, self.roots, self.run.config,
self.run.runtime if self.run.config is None else None)
error = None
if isinstance(value, dict):
if value.get('status') == 'rejected':
error = '; '.join(issue['message'] for issue in value['issues'])
elif value.get('error'):
error = str(value['error'])
return ToolResult(value={**value, 'error': error} if error else value, error=error)
except (ProjectError, ValidationError, ValueError, KeyError, OSError, UnicodeError) as failure:
return ToolResult(value={'error': str(failure)}, error=str(failure))

async def close(self) -> None:
self.closed = True
await asyncio.gather(*(task for _, task in self.calls.values()), return_exceptions=True)
Loading