chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:54 +08:00
commit ecb5ae4e59
153 changed files with 21551 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
__all__ = ["AgentLoop", "SessionIndex", "ToolRegistry", "build_runtime"]
def build_runtime(*args, **kwargs):
from .loop import build_runtime as _build_runtime
return _build_runtime(*args, **kwargs)
def __getattr__(name):
if name == "AgentLoop":
from .loop import AgentLoop
return AgentLoop
if name == "SessionIndex":
from .session_index import SessionIndex
return SessionIndex
if name == "ToolRegistry":
from .tools import ToolRegistry
return ToolRegistry
raise AttributeError(name)
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
DEFAULT_LLM_MODEL = "gpt-5.5"
DEFAULT_LLM_MODEL_PROVIDER = "openai"
DEFAULT_LLM_BASE_URL = "https://yunwu.ai/v1"
DEFAULT_IMAGE_MODEL = "gemini-3.1-flash-image-preview"
DEFAULT_IMAGE_BASE_URL = "https://yunwu.ai"
DEFAULT_VIDEO_MODEL = "veo3.1-fast"
DEFAULT_VIDEO_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDING_MODEL_PROVIDER = "openai"
DEFAULT_RERANKER_MODEL = "BAAI/bge-reranker-v2-m3"
@lru_cache(maxsize=4)
def load_agent_config(workspace_root: str | Path = ".") -> dict[str, Any]:
path = Path(workspace_root).resolve() / "configs" / "agent.local.yaml"
if not path.exists():
return {}
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
raise RuntimeError(f"Invalid configs/agent.local.yaml: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeError("configs/agent.local.yaml must be a YAML mapping")
return payload
def config_value(section: str, key: str, env_names: list[str], default: str = "", workspace_root: str | Path = ".") -> str:
for env_name in env_names:
value = os.environ.get(env_name)
if value:
return value
section_payload = load_agent_config(workspace_root).get(section, {})
if isinstance(section_payload, dict):
value = section_payload.get(key)
if isinstance(value, str) and value:
return value
return default
def llm_model(workspace_root: str | Path = ".") -> str:
return config_value("llm", "model", ["VIMAX_LLM_MODEL"], DEFAULT_LLM_MODEL, workspace_root)
def llm_model_provider(workspace_root: str | Path = ".") -> str:
return config_value("llm", "model_provider", ["VIMAX_LLM_MODEL_PROVIDER"], DEFAULT_LLM_MODEL_PROVIDER, workspace_root)
def llm_base_url(workspace_root: str | Path = ".") -> str:
return config_value("llm", "base_url", ["VIMAX_LLM_BASE_URL"], DEFAULT_LLM_BASE_URL, workspace_root)
def llm_api_key(workspace_root: str | Path = ".") -> str:
return config_value("llm", "api_key", ["VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], "", workspace_root)
def image_model(workspace_root: str | Path = ".") -> str:
return config_value("image", "model", ["VIMAX_IMAGE_MODEL"], DEFAULT_IMAGE_MODEL, workspace_root)
def image_base_url(workspace_root: str | Path = ".") -> str:
return config_value("image", "base_url", ["VIMAX_IMAGE_BASE_URL"], DEFAULT_IMAGE_BASE_URL, workspace_root)
def image_api_key(workspace_root: str | Path = ".") -> str:
return config_value("image", "api_key", ["VIMAX_IMAGE_API_KEY", "VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], llm_api_key(workspace_root), workspace_root)
def embedding_model(workspace_root: str | Path = ".") -> str:
return config_value("embedding", "model", ["VIMAX_EMBEDDING_MODEL"], DEFAULT_EMBEDDING_MODEL, workspace_root)
def embedding_model_provider(workspace_root: str | Path = ".") -> str:
return config_value("embedding", "model_provider", ["VIMAX_EMBEDDING_MODEL_PROVIDER"], DEFAULT_EMBEDDING_MODEL_PROVIDER, workspace_root)
def embedding_base_url(workspace_root: str | Path = ".") -> str:
return config_value("embedding", "base_url", ["VIMAX_EMBEDDING_BASE_URL"], "", workspace_root)
def embedding_api_key(workspace_root: str | Path = ".") -> str:
return config_value("embedding", "api_key", ["VIMAX_EMBEDDING_API_KEY"], "", workspace_root)
def reranker_model(workspace_root: str | Path = ".") -> str:
return config_value("reranker", "model", ["VIMAX_RERANKER_MODEL"], DEFAULT_RERANKER_MODEL, workspace_root)
def reranker_base_url(workspace_root: str | Path = ".") -> str:
return config_value("reranker", "base_url", ["VIMAX_RERANKER_BASE_URL"], "", workspace_root)
def reranker_api_key(workspace_root: str | Path = ".") -> str:
return config_value("reranker", "api_key", ["VIMAX_RERANKER_API_KEY"], "", workspace_root)
def video_model(workspace_root: str | Path = ".") -> str:
return config_value("video", "model", ["VIMAX_VIDEO_MODEL"], DEFAULT_VIDEO_MODEL, workspace_root)
def video_base_url(workspace_root: str | Path = ".") -> str:
return config_value("video", "base_url", ["VIMAX_VIDEO_BASE_URL"], DEFAULT_VIDEO_BASE_URL, workspace_root)
def video_api_key(workspace_root: str | Path = ".") -> str:
return config_value("video", "api_key", ["VIMAX_VIDEO_API_KEY", "VIMAX_LLM_API_KEY", "VIMAX_API_KEY"], llm_api_key(workspace_root), workspace_root)
def api_provider_from_base_url(base_url: str) -> str:
normalized = base_url.strip().lower()
if "openrouter.ai" in normalized:
return "openrouter"
if "yunwu.ai" in normalized:
return "yunwu"
return ""
def video_provider(workspace_root: str | Path = ".") -> str:
"""Infer the video API relay/provider from video.base_url.
This is not a model provider setting. OpenRouter/Yunwu are transport/API
gateways here, so users should configure base_url and let the adapter pick
the matching implementation.
"""
return api_provider_from_base_url(video_base_url(workspace_root))
+254
View File
@@ -0,0 +1,254 @@
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
SUMMARY_SECTIONS = [
"Reference Context Only",
"Active Task",
"Completed Actions",
"Important Files",
"Decisions",
"Errors & Risks",
"Remaining Work",
"Critical Context",
]
@dataclass(slots=True)
class CompactionResult:
summary: str
preserved_messages: list[dict[str, Any]]
compacted_message_count: int
estimated_tokens_before: int
estimated_tokens_after: int
reason: str
mode: str
created_at: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
class ContextCompactor:
def __init__(
self,
llm: Any | None = None,
*,
token_threshold: int | None = None,
buffer_tokens: int | None = None,
preserve_last_n: int | None = None,
max_messages: int | None = None,
summary_max_chars: int | None = None,
) -> None:
self.llm = llm
configured_threshold = token_threshold if token_threshold is not None else _default_token_threshold()
self.token_threshold = _env_int("VIMAX_AUTO_COMPACT_TOKEN_THRESHOLD", configured_threshold)
self.buffer_tokens = _env_int("VIMAX_AUTO_COMPACT_BUFFER_TOKENS", buffer_tokens if buffer_tokens is not None else 20000)
self.preserve_last_n = _env_int("VIMAX_COMPACT_PRESERVE_LAST_N", preserve_last_n if preserve_last_n is not None else 6)
self.max_messages = _env_int("VIMAX_COMPACT_MAX_MESSAGES", max_messages if max_messages is not None else 48)
self.summary_max_chars = _env_int("VIMAX_COMPACT_SUMMARY_MAX_CHARS", summary_max_chars if summary_max_chars is not None else 6000)
def compact_target_tokens(self) -> int:
if self.token_threshold <= 0:
return 0
return max(0, self.token_threshold - max(0, self.buffer_tokens))
def estimate_message_tokens(self, message: dict[str, Any]) -> int:
role = str(message.get("role", "user") or "user")
content = str(message.get("content", "") or "")
metadata = {key: value for key, value in message.items() if key not in {"role", "content"}}
word_count = len(re.findall(r"\w+", content))
line_count = content.count("\n") + 1 if content else 0
punctuation_count = len(re.findall(r"[^\w\s]", content))
role_overhead = {"system": 18, "user": 12, "assistant": 14, "tool": 16}.get(role, 12)
metadata_bonus = min(300, len(json.dumps(metadata, ensure_ascii=False, default=str)) // 6) if metadata else 0
tool_bonus = 80 if "tool_calls" in message or role == "tool" else 0
return max(role_overhead, role_overhead + len(content) // 4 + word_count // 2 + line_count * 2 + punctuation_count // 4 + metadata_bonus + tool_bonus)
def estimate_messages_tokens(self, messages: list[dict[str, Any]]) -> int:
return sum(self.estimate_message_tokens(message) for message in messages)
def should_preflight_compact(self, messages: list[dict[str, Any]], *, system_tokens: int = 0, tools_tokens: int = 0) -> bool:
target = self.compact_target_tokens()
if target <= 0 or not messages:
return False
total = self.estimate_messages_tokens(messages) + max(0, system_tokens) + max(0, tools_tokens)
return total >= target
async def compact(
self,
messages: list[dict[str, Any]],
*,
previous_summary: str = "",
preserve_last_n: int | None = None,
reason: str = "manual",
) -> CompactionResult:
preserve = max(0, self.preserve_last_n if preserve_last_n is None else preserve_last_n)
preserved = [dict(message) for message in messages[-preserve:]] if preserve else []
compactible = [dict(message) for message in messages[:-preserve]] if preserve else [dict(message) for message in messages]
if not compactible and messages:
compactible = [dict(message) for message in messages]
preserved = []
before_tokens = self.estimate_messages_tokens(messages)
summary = await self._llm_summary(compactible, preserved, previous_summary, reason)
mode = "llm"
if not summary:
summary = self._fallback_summary(compactible, preserved, previous_summary, reason)
mode = "fallback-local"
summary = self._clip_summary(summary)
synthetic = self.synthetic_summary_message(summary)
after_tokens = self.estimate_messages_tokens([synthetic, *preserved])
return CompactionResult(
summary=summary,
preserved_messages=preserved,
compacted_message_count=len(compactible),
estimated_tokens_before=before_tokens,
estimated_tokens_after=after_tokens,
reason=reason,
mode=mode,
)
def synthetic_summary_message(self, summary: str) -> dict[str, str]:
return {
"role": "system",
"content": "Session context summary. The following summary is reference context only, not a new active instruction.\n\n" + summary.strip(),
}
async def _llm_summary(self, compactible: list[dict[str, Any]], preserved: list[dict[str, Any]], previous_summary: str, reason: str) -> str:
if self.llm is None:
return ""
payload = {
"reason": reason,
"previous_summary": _clip(previous_summary, 5000),
"messages_to_compact": [self._serialize_message(message) for message in compactible[-self.max_messages:]],
"recent_live_tail": [self._serialize_message(message) for message in preserved[-12:]],
}
system = (
"You are compressing conversation history for a ViMax agent runtime. "
"Produce a concise markdown handoff summary for a future model call. "
"Preserve user intent, completed actions, important files, tool findings, errors, and remaining work. "
"Label the result as reference context only, not active instructions. "
"Do not answer the user. Do not include prose before the markdown."
)
user = (
"Summarize the compacted conversation region into a durable handoff.\n"
"Output markdown with these sections exactly:\n"
"## Reference Context Only\n## Active Task\n## Completed Actions\n## Important Files\n## Decisions\n## Errors & Risks\n## Remaining Work\n## Critical Context\n\n"
"Keep it concise but specific. Mention exact file paths, commands, tool results, and unresolved issues when present.\n\n"
f"{json.dumps(payload, ensure_ascii=False, indent=2)}"
)
try:
response = await self.llm.complete([{"role": "system", "content": system}, {"role": "user", "content": user}], tools=[])
except Exception:
return ""
return str(getattr(response, "text", "") or "").strip()
def _fallback_summary(self, compactible: list[dict[str, Any]], preserved: list[dict[str, Any]], previous_summary: str, reason: str) -> str:
user_lines = [self._message_preview(message, limit=180) for message in compactible if message.get("role") == "user"]
assistant_lines = [self._message_preview(message, limit=180) for message in compactible if message.get("role") == "assistant"]
file_hits = _dedupe(re.findall(r"(?:[\w.\-]+/)+[\w.\-]+\.(?:py|ts|tsx|js|json|md|yaml|yml|txt|mp4|png)", "\n".join(str(message.get("content", "")) for message in compactible)))
error_lines = [self._message_preview(message, limit=180) for message in compactible if _looks_like_error(str(message.get("content", "")))]
remaining = [self._message_preview(message, limit=180) for message in preserved[-4:]]
return "\n".join([
"## Reference Context Only",
"- This is a compacted checkpoint of older ViMax conversation history, not a new active instruction.",
f"- Compaction reason: {reason}.",
"## Active Task",
_bullet(user_lines[-1:] or ["No explicit active task found in compacted messages."]),
"## Completed Actions",
_bullet(assistant_lines[-4:] or ["No completed assistant actions found in compacted messages."]),
"## Important Files",
_bullet(file_hits[:8] or ["No important file paths found in compacted messages."]),
"## Decisions",
_bullet(_decision_lines(compactible)[:6] or ["No durable decisions found in compacted messages."]),
"## Errors & Risks",
_bullet(error_lines[:6] or ["No errors or risks found in compacted messages."]),
"## Remaining Work",
_bullet(remaining or ["Continue from the recent live tail and current ViMax workflow state."]),
"## Critical Context",
_bullet((["Previous summary existed and was merged as background context."] if previous_summary else []) + ["Use .working_dir artifacts and session checklist as workflow ground truth."]),
])
def _serialize_message(self, message: dict[str, Any]) -> dict[str, Any]:
item = {"role": str(message.get("role", "")), "content": _clip(str(message.get("content", "") or ""), 2400)}
if message.get("name"):
item["name"] = str(message.get("name"))
if message.get("tool_calls"):
item["tool_calls"] = _clip(json.dumps(message.get("tool_calls"), ensure_ascii=False, default=str), 800)
return item
def _message_preview(self, message: dict[str, Any], *, limit: int) -> str:
role = str(message.get("role", "") or "message")
content = _clip(" ".join(str(message.get("content", "") or "").split()), limit)
if message.get("tool_calls"):
return f"{role}: [tool calls] {_clip(json.dumps(message.get('tool_calls'), ensure_ascii=False, default=str), limit)}"
return f"{role}: {content}" if content else f"{role}: <empty>"
def _clip_summary(self, summary: str) -> str:
text = summary.strip()
if not text:
text = self._fallback_summary([], [], "", "empty-summary")
if len(text) > self.summary_max_chars:
text = text[: max(0, self.summary_max_chars - 3)].rstrip() + "..."
return text
def _default_token_threshold() -> int:
context_window = _env_int("VIMAX_CONTEXT_WINDOW_TOKENS", 200000)
ratio = _env_float("VIMAX_AUTO_COMPACT_RATIO", 0.90)
ratio = min(1.0, max(0.0, ratio))
return int(context_window * ratio)
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, str(default)))
except ValueError:
return default
def _env_float(name: str, default: float) -> float:
try:
return float(os.environ.get(name, str(default)))
except ValueError:
return default
def _clip(text: str, limit: int) -> str:
compact = " ".join(str(text or "").split())
if len(compact) <= limit:
return compact
return compact[: max(0, limit - 3)].rstrip() + "..."
def _bullet(items: list[str]) -> str:
return "\n".join(f"- {item}" for item in items if str(item).strip())
def _dedupe(items: list[str]) -> list[str]:
seen: list[str] = []
for item in items:
normalized = " ".join(str(item).split())
if normalized and normalized not in seen:
seen.append(normalized)
return seen
def _looks_like_error(text: str) -> bool:
lowered = text.lower()
return any(token in lowered for token in ("error", "failed", "failure", "timeout", "not found", "blocked", "permission"))
def _decision_lines(messages: list[dict[str, Any]]) -> list[str]:
tokens = ("decision", "decided", "prefer", "keep ", "switch ", "use ", "preserve ", "avoid ")
rows: list[str] = []
for message in messages:
content = str(message.get("content", "") or "")
for raw in content.splitlines():
line = raw.strip(" -")
if line and any(token in line.lower() for token in tokens):
rows.append(_clip(line, 180))
return _dedupe(rows)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
from openai import APIConnectionError, APITimeoutError, AsyncOpenAI
from .config import llm_api_key, llm_base_url, llm_model
from .models import ToolCall
LLM_MAX_ATTEMPTS = 3
LLM_RETRY_BACKOFF_SECONDS = (1.0, 4.0)
LLM_REQUEST_TIMEOUT_SECONDS = 300.0
def _is_retryable_llm_error(exc: BaseException) -> bool:
status = getattr(exc, "status_code", None)
if status is not None:
try:
status = int(status)
except (TypeError, ValueError):
return False
return status == 429 or status >= 500
return isinstance(exc, (APIConnectionError, APITimeoutError))
class LLMResponseShapeError(RuntimeError):
pass
@dataclass(slots=True)
class AssistantMessage:
text: str = ""
tool_calls: list[ToolCall] = field(default_factory=list)
raw_message: dict[str, Any] = field(default_factory=dict)
class OpenAICompatibleLLM:
def __init__(self, model: str | None = None, base_url: str | None = None, api_key: str | None = None) -> None:
self.model = model or llm_model()
self.base_url = base_url or llm_base_url()
self.api_key = api_key or llm_api_key()
if not self.api_key:
raise RuntimeError("VIMAX_LLM_API_KEY is required for the agent LLM client")
self.client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, timeout=LLM_REQUEST_TIMEOUT_SECONDS)
async def complete(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]) -> AssistantMessage:
shape_attempts = [
{"tools": tools or None, "tool_choice": "auto" if tools else None},
{"tools": tools or None, "tool_choice": "auto" if tools else None},
]
if tools:
shape_attempts.append({"tools": None, "tool_choice": None})
last_shape_error: Exception | None = None
for attempt in shape_attempts:
try:
response = await self._create_completion_with_retries(messages, attempt["tools"], attempt["tool_choice"])
return _assistant_message_from_response(response)
except LLMResponseShapeError as exc:
last_shape_error = exc
continue
assert last_shape_error is not None
raise last_shape_error
async def _create_completion_with_retries(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, tool_choice: str | None) -> Any:
for attempt in range(LLM_MAX_ATTEMPTS):
try:
return await self._create_completion(messages, tools, tool_choice)
except Exception as exc:
if isinstance(exc, LLMResponseShapeError) or attempt == LLM_MAX_ATTEMPTS - 1 or not _is_retryable_llm_error(exc):
raise
delay = LLM_RETRY_BACKOFF_SECONDS[min(attempt, len(LLM_RETRY_BACKOFF_SECONDS) - 1)]
logging.warning("LLM call failed (%s); retrying in %.1fs (attempt %d/%d)", exc, delay, attempt + 1, LLM_MAX_ATTEMPTS)
await asyncio.sleep(delay)
async def _create_completion(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None, tool_choice: str | None) -> Any:
kwargs: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": False,
}
if tools:
kwargs["tools"] = tools
if tool_choice:
kwargs["tool_choice"] = tool_choice
return await self.client.chat.completions.create(**kwargs)
def _assistant_message_from_response(response: Any) -> AssistantMessage:
message = _extract_message(response)
text = _message_value(message, "content") or ""
calls: list[ToolCall] = []
for call in _message_value(message, "tool_calls") or []:
function = _message_value(call, "function") or {}
try:
arguments = json.loads(_message_value(function, "arguments") or "{}")
except json.JSONDecodeError:
arguments = {}
calls.append(ToolCall(id=_message_value(call, "id") or f"tool-{uuid4().hex[:12]}", name=_message_value(function, "name"), arguments=arguments))
return AssistantMessage(text=text, tool_calls=calls, raw_message=_dump_message(message))
def _extract_message(response: Any) -> Any:
if isinstance(response, str):
try:
response = json.loads(response)
except json.JSONDecodeError as exc:
raise LLMResponseShapeError(f"LLM provider returned a string instead of a chat completion object: {response[:300]}") from exc
choices = _message_value(response, "choices")
if not choices:
raise LLMResponseShapeError(f"LLM provider response missing choices: {str(response)[:500]}")
first_choice = choices[0]
message = _message_value(first_choice, "message")
if message is None:
raise LLMResponseShapeError(f"LLM provider response missing choice.message: {str(response)[:500]}")
return message
def _message_value(obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
def _dump_message(message: Any) -> dict[str, Any]:
if isinstance(message, dict):
return message
if hasattr(message, "model_dump"):
return message.model_dump()
return {"content": str(message)}
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
import json
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncIterator
from .context_compactor import ContextCompactor, CompactionResult
from .llm import OpenAICompatibleLLM
from .models import ToolCall, ToolResult, TurnControl
from .prompts import PromptBuilder
from .session_index import SessionIndex
from .tool_executor import ToolExecutor
from .tools import ToolRegistry, build_builtin_registry
MAX_TOOL_PASSES = 50
class AgentLoop:
def __init__(self, session_index: SessionIndex, prompt_builder: PromptBuilder, tool_registry: ToolRegistry, tool_executor: ToolExecutor, llm: Any, context_compactor: ContextCompactor | None = None) -> None:
self.session_index = session_index
self.prompt_builder = prompt_builder
self.tool_registry = tool_registry
self.tool_executor = tool_executor
self.llm = llm
self.context_compactor = context_compactor or ContextCompactor(llm)
self.history: list[dict[str, Any]] = []
async def compact_history(self, *, reason: str = "manual") -> str:
if not self.history:
return "No conversation history to compact."
session = self.session_index.active() or self.session_index.create()
result = await self.context_compactor.compact(
self.history,
previous_summary=str(session.get("compacted_summary", "") or ""),
reason=reason,
)
self.history = [self.context_compactor.synthetic_summary_message(result.summary), *result.preserved_messages]
self.session_index.update_compaction(session["session_id"], _compaction_record(result))
return f"Compacted context {result.estimated_tokens_before} -> {result.estimated_tokens_after} ({result.mode})."
async def stream_events(self, user_input: str) -> AsyncIterator[dict[str, Any]]:
control = TurnControl()
yield {"type": "turn", "turn_id": control.turn_id, "turn": {"id": control.turn_id}}
tool_schemas = self.tool_registry.list_function_tools()
parts = self.prompt_builder.build_parts(user_input)
system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user")
if self.context_compactor.should_preflight_compact(
[*self.history, {"role": "user", "content": user_input}],
system_tokens=_prompt_tokens(parts),
tools_tokens=_tool_schema_tokens(tool_schemas),
):
yield {"type": "status", "turn_id": control.turn_id, "phase": "compact", "message": "Compacting context before sampling"}
await self.compact_history(reason="token-pressure")
parts = self.prompt_builder.build_parts(user_input)
system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user")
yield {"type": "prompt_trace", "turn_id": control.turn_id, "prompt_trace": self.prompt_builder.trace(parts)}
runtime_messages: list[dict[str, Any]] = [{"role": "system", "content": system}, *self.history, {"role": "user", "content": user_input}]
assistant_turns: list[dict[str, Any]] = []
tool_rounds: list[dict[str, Any]] = []
transitions: list[dict[str, str]] = []
all_tool_results: list[ToolResult] = []
final_text = ""
status = "completed"
tool_round = 0
while True:
yield {"type": "status", "turn_id": control.turn_id, "phase": "sampling_assistant", "message": "Sampling assistant"}
try:
assistant = await self.llm.complete(runtime_messages, tools=tool_schemas)
except Exception as exc:
status = "failed"
final_text = f"Agent LLM request failed: {exc}"
transitions.append(_transition("sampling_assistant", "finalizing_answer", "llm_sampling_failed"))
yield {"type": "error", "turn_id": control.turn_id, "message": final_text, "metadata": {"error_type": "llm_sampling_failed"}}
break
assistant_turns.append({"phase": "initial" if tool_round == 0 else f"followup_{tool_round}", "text": assistant.text, "tool_calls": [call.as_dict() for call in assistant.tool_calls]})
if not assistant.tool_calls:
transitions.append(_transition("sampling_assistant", "finalizing_answer", "assistant_finished_without_tools"))
final_text = assistant.text
if final_text:
yield {"type": "token", "turn_id": control.turn_id, "delta": final_text}
break
transitions.append(_transition("sampling_assistant", "executing_tools", "assistant_requested_tools"))
if tool_round >= MAX_TOOL_PASSES:
status = "halted"
final_text = "Tool loop halted after max tool passes."
transitions.append(_transition("executing_tools", "finalizing_answer", "max_tool_passes_reached"))
yield {"type": "error", "turn_id": control.turn_id, "message": final_text, "metadata": {"max_tool_passes": MAX_TOOL_PASSES}}
break
tool_round += 1
yield {"type": "status", "turn_id": control.turn_id, "phase": "executing_tools", "message": f"Running tools (round {tool_round})"}
runtime_messages.append({"role": "assistant", "content": assistant.text or "", "tool_calls": [_openai_tool_call(call) for call in assistant.tool_calls]})
round_results: list[ToolResult] = []
for call in assistant.tool_calls:
yield {"type": "tool_start", "turn_id": control.turn_id, "tool": call.as_dict()}
progress_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
def on_progress(event: dict[str, Any]) -> None:
progress_queue.put_nowait(event)
task = asyncio.create_task(self.tool_executor.execute(call, control, progress_callback=on_progress))
while not task.done():
try:
yield await asyncio.wait_for(progress_queue.get(), timeout=0.1)
except asyncio.TimeoutError:
continue
while not progress_queue.empty():
yield progress_queue.get_nowait()
record = await task
result = record.result
round_results.append(result)
all_tool_results.append(result)
yield {"type": "tool_result", "turn_id": control.turn_id, "tool_result": result.as_dict()}
runtime_messages.append({"role": "tool", "tool_call_id": call.id, "name": result.name, "content": json.dumps(result.as_dict(), ensure_ascii=False)})
tool_rounds.append({"tool_round": tool_round, "requested_tools": [call.as_dict() for call in assistant.tool_calls], "tool_results": [result.as_dict() for result in round_results]})
transitions.append(_transition("executing_tools", "post_tool_decision", "tool_round_completed"))
transitions.append(_transition("post_tool_decision", "sampling_assistant", "runtime_continuation_after_tools"))
self.history.extend([{"role": "user", "content": user_input}, {"role": "assistant", "content": final_text}])
turn_record = {"turn_id": control.turn_id, "status": status, "raw_user_input": user_input, "assistant_turns": assistant_turns, "tool_rounds": tool_rounds, "transitions": transitions, "final_assistant_text": final_text, "created_at": datetime.now().isoformat(timespec="seconds")}
final_session = self.session_index.active() or self.session_index.create()
self.session_index.append_turn_record(final_session["session_id"], turn_record)
yield {"type": "done", "turn_id": control.turn_id, "assistant": final_text, "tool_results": [result.as_dict() for result in all_tool_results]}
yield {"type": "session", "turn_id": control.turn_id, "session": self.session_index.snapshot()}
def _compaction_record(result: CompactionResult) -> dict[str, Any]:
return {
"summary": result.summary,
"preserved_message_count": len(result.preserved_messages),
"compacted_message_count": result.compacted_message_count,
"estimated_tokens_before": result.estimated_tokens_before,
"estimated_tokens_after": result.estimated_tokens_after,
"reason": result.reason,
"mode": result.mode,
"created_at": result.created_at,
}
def _prompt_tokens(parts: list[Any]) -> int:
return sum(max(1, len(str(getattr(part, "body", ""))) // 4) for part in parts)
def _tool_schema_tokens(tool_schemas: list[dict[str, Any]]) -> int:
try:
return max(0, len(json.dumps(tool_schemas, ensure_ascii=False, default=str)) // 4)
except TypeError:
return max(0, len(str(tool_schemas)) // 4)
def _transition(src: str, dst: str, reason: str) -> dict[str, str]:
return {"from": src, "to": dst, "reason": reason}
def _openai_tool_call(call: ToolCall) -> dict[str, Any]:
return {"id": call.id, "type": "function", "function": {"name": call.name, "arguments": json.dumps(call.arguments, ensure_ascii=False)}}
def build_runtime(workspace_root: str | Path = ".", llm: Any | None = None, adapter_specs: list[Any] | None = None) -> AgentLoop:
from .vimax_adapters import build_vimax_adapter_specs
root = Path(workspace_root).resolve()
session_index = SessionIndex(root)
specs = adapter_specs if adapter_specs is not None else build_vimax_adapter_specs(root, session_index)
registry = build_builtin_registry(root, session_index, specs)
executor = ToolExecutor(registry, session_index)
prompt_builder = PromptBuilder(root / "prompts", session_index, registry)
resolved_llm = llm or OpenAICompatibleLLM()
return AgentLoop(session_index, prompt_builder, registry, executor, resolved_llm, ContextCompactor(resolved_llm))
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from dataclasses import dataclass, field
from threading import Event
from time import time
from typing import Any, Literal
from uuid import uuid4
@dataclass(slots=True)
class ToolCall:
name: str
arguments: dict[str, Any] = field(default_factory=dict)
id: str = field(default_factory=lambda: f"tool-{uuid4().hex[:12]}")
def as_dict(self) -> dict[str, Any]:
return {"id": self.id, "name": self.name, "arguments": self.arguments}
@dataclass(slots=True)
class ToolResult:
name: str
ok: bool
content: str
metadata: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {"name": self.name, "ok": self.ok, "content": self.content, "metadata": dict(self.metadata)}
@dataclass(slots=True)
class TurnControl:
turn_id: str = field(default_factory=lambda: f"turn-{uuid4().hex[:12]}")
cancel_event: Event = field(default_factory=Event)
cancel_reason: str = ""
def cancel(self, reason: str = "") -> None:
self.cancel_reason = reason.strip()
self.cancel_event.set()
@dataclass(slots=True)
class SessionRecord:
session_id: str
working_dir: str
idea: str = ""
user_requirement: str = ""
style: str = ""
stage: str = "created"
summary: str = ""
stale: dict[str, bool] = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
StreamEventType = Literal["turn", "status", "token", "tool_start", "tool_progress", "tool_result", "terminal", "done", "session", "error", "prompt_trace"]
def now_ts() -> float:
return time()
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(slots=True)
class PromptPart:
id: str
title: str
body: str
zone: str
category: str
cacheable: bool = False
class PromptBuilder:
def __init__(self, prompt_dir: str | Path, session_index: Any, tool_registry: Any) -> None:
self.prompt_dir = Path(prompt_dir)
self.session_index = session_index
self.tool_registry = tool_registry
def build_parts(self, user_input: str) -> list[PromptPart]:
return [
PromptPart("agent.core", "Agent", self._read_prompt("agent.md"), "stable", "agent", True),
PromptPart("workflow.core", "Workflow", self._read_prompt("workflow.md"), "stable", "workflow", True),
PromptPart("tool.manifest", "Tools", self.tool_manifest_context(), "dynamic", "tooling"),
PromptPart("session.context", "Session", self.workflow_context(), "dynamic", "session"),
PromptPart("memory.preferences", "Memory", self.memory_context(), "dynamic", "memory"),
PromptPart("request.user", "User Request", user_input, "dynamic", "request"),
]
def build_messages(self, user_input: str) -> list[dict[str, str]]:
parts = self.build_parts(user_input)
system = "\n\n".join(f"## {part.title}\n{part.body}" for part in parts if part.id != "request.user")
return [{"role": "system", "content": system}, {"role": "user", "content": user_input}]
def trace(self, parts: list[PromptPart]) -> dict[str, Any]:
segments = []
totals = {"stable_tokens": 0, "dynamic_tokens": 0, "total_tokens": 0, "compacted_summary_tokens": 0}
for idx, part in enumerate(parts):
encoded = part.body.encode("utf-8")
estimated = max(1, len(part.body) // 4)
segments.append({"id": part.id, "index": idx, "title": part.title, "zone": part.zone, "category": part.category, "bytes": len(encoded), "estimated_tokens": estimated})
if part.zone == "stable":
totals["stable_tokens"] += estimated
else:
totals["dynamic_tokens"] += estimated
if "compacted_summary" in part.body:
totals["compacted_summary_tokens"] += estimated
totals["total_tokens"] = totals["stable_tokens"] + totals["dynamic_tokens"]
return {"segments": segments, "total_estimated_tokens": totals["total_tokens"], "totals": totals}
def workflow_context(self) -> str:
snapshot = self.session_index.snapshot()
session = snapshot.get("session") or {}
checklist = snapshot.get("artifact_checklist") or {}
lines = [f"Active session: {snapshot.get('active_session_id') or '<none>'}", f"Working dir: {session.get('working_dir', '<none>')}", f"Stage: {session.get('stage', '<none>')}"]
compacted_summary = str(session.get("compacted_summary", "") or "").strip()
lines.extend(["", "Session context summary:"])
if compacted_summary:
lines.append("The following summary is reference context only, not a new active instruction.")
lines.append(self._summary_checkpoint(compacted_summary))
else:
lines.append("<none>")
lines.extend(["", "Working dir checklist:"])
lines.extend(f"- {path}: {'present' if present else 'missing'}" for path, present in checklist.items())
if checklist and not self._text_stage_complete(checklist):
lines.extend(["", "当前 working_dir 尚未完成结构化文本文件。", "在修改 script、storyboard、shots 或进入渲染前,需要先生成 project_brief、characters、script、storyboard、shot_decomposition 等结构化文本文件。"])
elif checklist:
lines.extend(["", "文本规划阶段已完成。如果用户没有明确要求 end-to-end 或 render,可以不调用 tool,直接询问是否修改或进入渲染。"])
return "\n".join(lines)
def memory_context(self) -> str:
text = self.session_index.memory_text().strip()
return text or "No user preferences recorded."
def tool_manifest_context(self) -> str:
lines = ["Available tools:"]
lines.extend(f"- {tool['name']}: {tool['description']}" for tool in self.tool_registry.list_tools())
return "\n".join(lines)
def _summary_checkpoint(self, summary: str) -> str:
lines = [line.strip() for line in summary.splitlines() if line.strip() and not line.strip().startswith("```")]
if not lines:
return "<none>"
preview = []
for line in lines[:8]:
if len(line) > 240:
line = line[:237].rstrip() + "..."
preview.append(line if line.startswith("-") or line.startswith("#") else f"- {line}")
if len(lines) > 8:
preview.append(f"- <trimmed +{len(lines) - 8} lines>")
return "\n".join(preview)
def _read_prompt(self, name: str) -> str:
path = self.prompt_dir / name
return path.read_text(encoding="utf-8") if path.exists() else ""
def _text_stage_complete(self, checklist: dict[str, bool]) -> bool:
idea_mode_complete = bool(checklist.get("idea2video/story.txt") and checklist.get("idea2video/characters.json") and checklist.get("idea2video/script.json") and checklist.get("idea2video/scene_*/storyboard.json") and checklist.get("idea2video/scene_*/shots/*/shot_description.json") and checklist.get("idea2video/scene_*/camera_tree.json"))
script_mode_complete = bool(checklist.get("script2video/script.txt") and checklist.get("script2video/characters.json") and checklist.get("script2video/storyboard.json") and checklist.get("script2video/shots/*/shot_description.json") and checklist.get("script2video/camera_tree.json"))
novel_mode_complete = bool(checklist.get("novel2video/novel/novel_compressed.txt") and checklist.get("novel2video/events/event_*.json") and checklist.get("novel2video/relevant_chunks/event_*") and checklist.get("novel2video/scenes/event_*/scene_*.json") and checklist.get("novel2video/global_information/characters/novel_level/*.json"))
return idea_mode_complete or script_mode_complete or novel_mode_complete
+336
View File
@@ -0,0 +1,336 @@
from __future__ import annotations
import json
import logging
import os
import re
from contextlib import contextmanager
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Any
try:
import fcntl
except ImportError: # pragma: no cover - non-POSIX platforms
fcntl = None
STALE_KEYS = ["story", "characters", "script", "storyboard", "shot_descriptions", "camera_tree", "frames", "clips", "final_video"]
def _synchronized(method):
"""Hold the index file lock across a read-modify-write cycle.
Every mutator loads the whole sessions file, edits it, and saves it back;
without a lock, two concurrent writers (threads or processes) silently
drop each other's updates.
"""
@wraps(method)
def wrapper(self, *args, **kwargs):
with self._locked():
return method(self, *args, **kwargs)
return wrapper
class SessionIndex:
def __init__(self, workspace_root: str | Path) -> None:
self.workspace_root = Path(workspace_root).resolve()
self.vimax_dir = self.workspace_root / ".vimax"
self.sessions_path = self.vimax_dir / "sessions.json"
self.memory_path = self.vimax_dir / "memory.md"
self.logs_dir = self.vimax_dir / "logs"
self.working_root = self.workspace_root / ".working_dir"
self.vimax_dir.mkdir(parents=True, exist_ok=True)
self.logs_dir.mkdir(parents=True, exist_ok=True)
self.working_root.mkdir(parents=True, exist_ok=True)
if not self.memory_path.exists():
self.memory_path.write_text("# User Preferences\n", encoding="utf-8")
if not self.sessions_path.exists():
self.save({"active_session_id": "", "sessions": {}})
@contextmanager
def _locked(self):
if fcntl is None:
yield
return
lock_path = self.vimax_dir / "sessions.lock"
with open(lock_path, "a+", encoding="utf-8") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def load(self) -> dict[str, Any]:
try:
return json.loads(self.sessions_path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {"active_session_id": "", "sessions": {}}
except json.JSONDecodeError:
# A corrupt file usually means a crash mid-write. Returning empty
# state is fine for this call, but the next save() would overwrite
# the file and destroy every session — keep the evidence first.
backup = self.sessions_path.with_name(f"sessions.json.corrupt-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}")
try:
os.replace(self.sessions_path, backup)
logging.error("sessions.json was corrupt; preserved it at %s and starting with empty state", backup)
except OSError:
logging.error("sessions.json is corrupt and could not be backed up; starting with empty state")
return {"active_session_id": "", "sessions": {}}
def save(self, data: dict[str, Any]) -> None:
tmp_path = self.sessions_path.with_name("sessions.json.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp_path, self.sessions_path)
def active(self) -> dict[str, Any] | None:
data = self.load()
session_id = str(data.get("active_session_id", ""))
if not session_id:
return None
record = data.get("sessions", {}).get(session_id)
return self._with_session_defaults(record) if isinstance(record, dict) else None
def get(self, session_id: str) -> dict[str, Any] | None:
normalized = self._normalize_session_id(session_id)
record = self.load().get("sessions", {}).get(normalized)
return self._with_session_defaults(record) if isinstance(record, dict) else None
@_synchronized
def create(self, idea: str = "", user_requirement: str = "", style: str = "", session_id: str | None = None) -> dict[str, Any]:
data = self.load()
sessions = data.setdefault("sessions", {})
final_id = self._normalize_session_id(session_id) if session_id else self._new_session_id(idea or user_requirement or "vimax", sessions)
if final_id in sessions:
final_id = self._dedupe_session_id(final_id, sessions)
now = datetime.now().isoformat(timespec="seconds")
working_dir = self._working_dir_for_id(final_id)
(working_dir / "idea2video").mkdir(parents=True, exist_ok=True)
(working_dir / "script2video").mkdir(parents=True, exist_ok=True)
record = {
"session_id": final_id,
"working_dir": str(working_dir.relative_to(self.workspace_root)),
"idea": idea,
"user_requirement": user_requirement,
"style": style,
"stage": "created",
"summary": "",
"stale": {key: False for key in STALE_KEYS},
"recent_turn_records": [],
"compacted_summary": "",
"compacted_turns": 0,
"compaction_snapshots": [],
"last_compaction_reason": "",
"last_compaction_at": "",
"created_at": now,
"updated_at": now,
}
sessions[final_id] = record
data["active_session_id"] = final_id
self.save(data)
return record
def get_or_create_active(self, idea: str = "", user_requirement: str = "", style: str = "") -> dict[str, Any]:
active = self.active()
if active is not None:
return active
return self.create(idea=idea, user_requirement=user_requirement, style=style)
@_synchronized
def set_active(self, session_id: str) -> dict[str, Any]:
normalized = self._normalize_session_id(session_id)
data = self.load()
if normalized not in data.get("sessions", {}):
raise KeyError(f"Unknown session_id: {session_id}")
data["active_session_id"] = normalized
self.save(data)
return dict(data["sessions"][normalized])
@_synchronized
def update_stage(self, session_id: str, stage: str, summary: str = "") -> None:
data = self.load()
record = data.get("sessions", {}).get(session_id)
if not isinstance(record, dict):
raise KeyError(f"Unknown session_id: {session_id}")
record["stage"] = stage
if summary:
record["summary"] = summary
record["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.save(data)
@_synchronized
def mark_stale(self, session_id: str, keys: list[str]) -> None:
data = self.load()
record = data.get("sessions", {}).get(session_id)
if not isinstance(record, dict):
raise KeyError(f"Unknown session_id: {session_id}")
stale = record.setdefault("stale", {key: False for key in STALE_KEYS})
for key in keys:
stale[key] = True
record["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.save(data)
@_synchronized
def update_compaction(self, session_id: str, result: dict[str, Any]) -> None:
data = self.load()
session = data.get("sessions", {}).get(session_id)
if not isinstance(session, dict):
raise KeyError(f"Unknown session_id: {session_id}")
summary = str(result.get("summary", "") or "")
compacted_count = int(result.get("compacted_message_count", 0) or 0)
snapshot = {
"level": len(session.get("compaction_snapshots", []) or []) + 1,
"reason": str(result.get("reason", "manual") or "manual"),
"mode": str(result.get("mode", "unknown") or "unknown"),
"summary": summary,
"preserved_messages": int(result.get("preserved_message_count", 0) or 0),
"compacted_message_count": compacted_count,
"estimated_tokens_before": int(result.get("estimated_tokens_before", 0) or 0),
"estimated_tokens_after": int(result.get("estimated_tokens_after", 0) or 0),
"created_at": str(result.get("created_at", "") or datetime.now().isoformat(timespec="seconds")),
}
session["compacted_summary"] = summary
session["compacted_turns"] = int(session.get("compacted_turns", 0) or 0) + max(1, compacted_count // 2)
snapshots = list(session.get("compaction_snapshots", []) or [])
snapshots.append(snapshot)
session["compaction_snapshots"] = snapshots[-8:]
session["last_compaction_reason"] = snapshot["reason"]
session["last_compaction_at"] = snapshot["created_at"]
session["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.save(data)
self.append_log("loop_history", {"session_id": session_id, "event": "context_compacted", "compaction": snapshot})
def compacted_summary(self, session_id: str | None = None) -> str:
record = self.get(session_id) if session_id else self.active()
return str((record or {}).get("compacted_summary", "") or "")
@_synchronized
def append_turn_record(self, session_id: str, record: dict[str, Any]) -> None:
data = self.load()
session = data.get("sessions", {}).get(session_id)
if isinstance(session, dict):
recent = session.setdefault("recent_turn_records", [])
recent.append({
"turn_id": record.get("turn_id", ""),
"status": record.get("status", ""),
"tool_round_count": len(record.get("tool_rounds", [])),
"final_preview": str(record.get("final_assistant_text", ""))[:240],
"created_at": record.get("created_at", ""),
})
session["recent_turn_records"] = recent[-6:]
session["updated_at"] = datetime.now().isoformat(timespec="seconds")
self.save(data)
self.append_log("loop_history", {"session_id": session_id, **record})
def working_dir(self, session_id: str | None = None) -> Path:
record = self.get(session_id) if session_id else self.active()
if record is None:
record = self.create()
path = (self.workspace_root / str(record["working_dir"])).resolve()
if path != self.working_root and self.working_root not in path.parents:
raise ValueError(f"Session working_dir escapes .working_dir: {record.get('working_dir')}")
path.mkdir(parents=True, exist_ok=True)
return path
def artifact_checklist(self, session_id: str | None = None) -> dict[str, bool]:
root = self.working_dir(session_id)
idea_dir = root / "idea2video"
idea_scene_dirs = sorted(path for path in idea_dir.glob("scene_*") if path.is_dir()) if idea_dir.exists() else []
idea_scene_storyboards = [path / "storyboard.json" for path in idea_scene_dirs]
idea_scene_camera_trees = [path / "camera_tree.json" for path in idea_scene_dirs]
idea_scene_shot_desc_groups = [list((scene / "shots").glob("*/shot_description.json")) for scene in idea_scene_dirs]
idea_scene_selector_outputs = [output for scene in idea_scene_dirs for output in (scene / "shots").glob("*/*_selector_output.json")]
script_shots = root / "script2video" / "shots"
script_shot_descs = list(script_shots.glob("*/shot_description.json")) if script_shots.exists() else []
script_selector_outputs = list(script_shots.glob("*/*_selector_output.json")) if script_shots.exists() else []
novel_dir = root / "novel2video"
novel_events = list((novel_dir / "events").glob("event_*.json")) if novel_dir.exists() else []
novel_relevant_chunks = [path for path in (novel_dir / "relevant_chunks").glob("event_*/*") if path.is_file()] if novel_dir.exists() else []
novel_scenes = list((novel_dir / "scenes").glob("event_*/scene_*.json")) if novel_dir.exists() else []
novel_event_chars = list((novel_dir / "global_information" / "characters" / "event_level").glob("event_*_characters.json")) if novel_dir.exists() else []
novel_level_chars = list((novel_dir / "global_information" / "characters" / "novel_level").glob("novel_characters_after_event_*.json")) if novel_dir.exists() else []
return {
"idea2video/story.txt": (idea_dir / "story.txt").exists(),
"idea2video/characters.json": (idea_dir / "characters.json").exists(),
"idea2video/script.json": (idea_dir / "script.json").exists(),
"idea2video/scene_*/storyboard.json": bool(idea_scene_storyboards) and all(path.exists() for path in idea_scene_storyboards),
"idea2video/scene_*/camera_tree.json": bool(idea_scene_camera_trees) and all(path.exists() for path in idea_scene_camera_trees),
"idea2video/scene_*/shots/*/shot_description.json": bool(idea_scene_shot_desc_groups) and all(idea_scene_shot_desc_groups),
"idea2video/scene_*/shots/*/*_selector_output.json": bool(idea_scene_selector_outputs),
"idea2video/final_video.mp4": (idea_dir / "final_video.mp4").exists(),
"script2video/script.txt": (root / "script2video" / "script.txt").exists(),
"script2video/characters.json": (root / "script2video" / "characters.json").exists(),
"script2video/storyboard.json": (root / "script2video" / "storyboard.json").exists(),
"script2video/shots/*/shot_description.json": bool(script_shot_descs),
"script2video/camera_tree.json": (root / "script2video" / "camera_tree.json").exists(),
"script2video/shots/*/*_selector_output.json": bool(script_selector_outputs),
"script2video/final_video.mp4": (root / "script2video" / "final_video.mp4").exists(),
"novel2video/novel/novel.txt": (novel_dir / "novel" / "novel.txt").exists(),
"novel2video/novel/novel_compressed.txt": (novel_dir / "novel" / "novel_compressed.txt").exists(),
"novel2video/events/event_*.json": bool(novel_events),
"novel2video/relevant_chunks/event_*": bool(novel_relevant_chunks),
"novel2video/scenes/event_*/scene_*.json": bool(novel_scenes),
"novel2video/global_information/characters/event_level/*.json": bool(novel_event_chars),
"novel2video/global_information/characters/novel_level/*.json": bool(novel_level_chars),
}
def memory_text(self) -> str:
return self.memory_path.read_text(encoding="utf-8") if self.memory_path.exists() else ""
def write_memory(self, text: str) -> None:
self.memory_path.write_text(text, encoding="utf-8")
def append_log(self, name: str, payload: dict[str, Any]) -> None:
event = {"timestamp": datetime.now().isoformat(timespec="seconds"), **payload}
path = self.logs_dir / f"{name}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
def snapshot(self) -> dict[str, Any]:
active = self.active()
if active is None:
return {"active_session_id": "", "session": None}
return {"active_session_id": active["session_id"], "session": active, "artifact_checklist": self.artifact_checklist(active["session_id"])}
def _with_session_defaults(self, record: dict[str, Any]) -> dict[str, Any]:
item = dict(record)
item.setdefault("compacted_summary", "")
item.setdefault("compacted_turns", 0)
item.setdefault("compaction_snapshots", [])
item.setdefault("last_compaction_reason", "")
item.setdefault("last_compaction_at", "")
item.setdefault("recent_turn_records", [])
return item
def _new_session_id(self, source: str, sessions: dict[str, Any]) -> str:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
slug = (re.sub(r"[^a-zA-Z0-9]+", "-", source.lower()).strip("-")[:32].strip("-") or "vimax")
return self._dedupe_session_id(f"{stamp}-{slug}", sessions)
def _dedupe_session_id(self, base: str, sessions: dict[str, Any]) -> str:
candidate = base
counter = 2
while candidate in sessions:
candidate = f"{base}-{counter}"
counter += 1
return candidate
def _normalize_session_id(self, session_id: str | None) -> str:
raw = str(session_id or "").strip()
if not raw:
raise ValueError("session_id cannot be empty")
normalized = re.sub(r"[^a-zA-Z0-9]+", "-", raw).strip("-")[:96]
if not normalized:
raise ValueError(f"Invalid session_id: {session_id}")
return normalized
def _working_dir_for_id(self, session_id: str) -> Path:
path = (self.working_root / session_id).resolve()
if path != self.working_root and self.working_root not in path.parents:
raise ValueError(f"Session path escapes .working_dir: {session_id}")
return path
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from time import time
from typing import Any, Callable
from .models import ToolCall, ToolResult, TurnControl
from .tools import ToolRegistry, ToolRuntimeContext
@dataclass(slots=True)
class ToolExecutionRecord:
requested_name: str
canonical_name: str
arguments_before: dict[str, Any]
arguments_after: dict[str, Any]
result: ToolResult
started_at: float
finished_at: float
telemetry: dict[str, Any]
class ToolExecutor:
def __init__(self, registry: ToolRegistry, session_index: Any) -> None:
self.registry = registry
self.session_index = session_index
async def execute(self, call: ToolCall, control: TurnControl, progress_callback: Callable[[dict[str, Any]], None] | None = None) -> ToolExecutionRecord:
requested_name = call.name
canonical_name = self.registry.resolve_name(call.name)
before = deepcopy(call.arguments)
started_at = time()
validated, validation_error = self.registry.validate_arguments(canonical_name, call.arguments)
arguments = validated if validated is not None else call.arguments
runtime = ToolRuntimeContext(requested_name=requested_name, canonical_name=canonical_name, turn_id=control.turn_id, cancel_event=control.cancel_event, progress_callback=progress_callback, metadata={"cancel_reason": control.cancel_reason})
if validation_error:
result = ToolResult(canonical_name, False, validation_error, {"validation_error": True})
elif control.cancel_event.is_set():
result = ToolResult(canonical_name, False, control.cancel_reason or "Tool execution cancelled", {"cancelled": True})
else:
result = await self.registry.execute(canonical_name, arguments, runtime=runtime)
finished_at = time()
telemetry = {"duration_ms": int((finished_at - started_at) * 1000), "requested_name": requested_name, "canonical_name": canonical_name, "result_ok": result.ok}
self.session_index.append_log("tool_calls", {"turn_id": control.turn_id, "tool": canonical_name, "arguments_preview": str(before)[:500], "ok": result.ok, "content_preview": result.content[:500], **telemetry})
return ToolExecutionRecord(requested_name, canonical_name, before, deepcopy(arguments), result, started_at, finished_at, telemetry)
+404
View File
@@ -0,0 +1,404 @@
from __future__ import annotations
import asyncio
import glob
import inspect
import json
import os
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from threading import Event
from typing import Any, Awaitable, Callable
from .models import ToolCall, ToolResult
ToolHandler = Callable[..., Awaitable[ToolResult] | ToolResult]
ProgressCallback = Callable[[dict[str, Any]], None]
@dataclass(slots=True)
class ToolArgumentSchema:
type: type | tuple[type, ...]
required: bool = False
default: Any = None
@dataclass(slots=True)
class ToolSpec:
name: str
description: str
handler: ToolHandler
aliases: tuple[str, ...] = ()
permission_mode: str = "workspace-write"
schema: dict[str, ToolArgumentSchema] | None = None
json_schema: dict[str, Any] | None = None
concurrency_safe: bool = False
@dataclass(slots=True)
class ToolRuntimeContext:
requested_name: str
canonical_name: str
turn_id: str = ""
cancel_event: Event | None = None
progress_callback: ProgressCallback | None = None
metadata: dict[str, Any] = field(default_factory=dict)
def emit_progress(self, message: str, *, stage: str = "running", metadata: dict[str, Any] | None = None) -> None:
if self.progress_callback is None:
return
payload: dict[str, Any] = {
"type": "tool_progress",
"tool": {"requested_name": self.requested_name, "name": self.canonical_name},
"progress": {"stage": stage, "message": message, "metadata": metadata or {}},
}
if self.turn_id:
payload["turn_id"] = self.turn_id
self.progress_callback(payload)
def emit_terminal(self, line: str, *, stream: str = "stdout") -> None:
if self.progress_callback is None:
return
if not line:
return
payload: dict[str, Any] = {"type": "terminal", "stream": stream, "line": line}
if self.turn_id:
payload["turn_id"] = self.turn_id
self.progress_callback(payload)
def is_cancelled(self) -> bool:
return self.cancel_event.is_set() if self.cancel_event is not None else False
def raise_if_cancelled(self, default_reason: str = "Tool execution cancelled") -> None:
if self.is_cancelled():
raise RuntimeError(str(self.metadata.get("cancel_reason") or default_reason))
class ToolRegistry:
def __init__(self, specs: list[ToolSpec] | None = None) -> None:
self._specs: dict[str, ToolSpec] = {}
self._aliases: dict[str, str] = {}
for spec in specs or []:
self.register(spec)
def register(self, spec: ToolSpec) -> None:
self._specs[spec.name] = spec
for alias in spec.aliases:
self._aliases[alias] = spec.name
def list_tools(self) -> list[dict[str, str]]:
return sorted([{"name": spec.name, "description": spec.description, "permission_mode": spec.permission_mode} for spec in self._specs.values()], key=lambda item: item["name"])
def list_function_tools(self) -> list[dict[str, Any]]:
tools = []
for spec in sorted(self._specs.values(), key=lambda item: item.name):
parameters = spec.json_schema or _argument_schema_to_json_schema(spec.schema or {})
tools.append({"type": "function", "function": {"name": spec.name, "description": spec.description, "parameters": parameters}})
return tools
def get_spec(self, name: str) -> ToolSpec | None:
return self._specs.get(self.resolve_name(name))
def resolve_name(self, name: str) -> str:
normalized = name.strip()
return self._aliases.get(normalized, normalized)
def validate_arguments(self, name: str, arguments: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
spec = self.get_spec(name)
if spec is None:
return None, f"Unknown tool: {name}"
schema = spec.schema or {}
normalized = dict(arguments or {})
for field_name, field_spec in schema.items():
if field_name not in normalized:
if field_spec.required and field_spec.default is None:
return None, f"Missing required argument '{field_name}' for {spec.name}"
if field_spec.default is not None:
normalized[field_name] = field_spec.default
continue
value = normalized[field_name]
expected = field_spec.type
if expected is bool and isinstance(value, str) and value.lower() in {"true", "false"}:
normalized[field_name] = value.lower() == "true"
continue
if expected is int and isinstance(value, str):
try:
normalized[field_name] = int(value)
continue
except ValueError:
return None, f"Argument '{field_name}' for {spec.name} must be an integer"
if not isinstance(normalized[field_name], expected):
expected_name = ", ".join(t.__name__ for t in expected) if isinstance(expected, tuple) else expected.__name__
return None, f"Argument '{field_name}' for {spec.name} must be {expected_name}"
return normalized, None
def is_concurrency_safe(self, name: str) -> bool:
spec = self.get_spec(name)
return bool(spec and spec.concurrency_safe)
def partition_calls(self, calls: list[ToolCall]) -> list[list[ToolCall]]:
batches: list[list[ToolCall]] = []
for call in calls:
if self.is_concurrency_safe(call.name) and batches and all(self.is_concurrency_safe(item.name) for item in batches[-1]):
batches[-1].append(call)
else:
batches.append([call])
return batches
async def execute(self, name: str, arguments: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
canonical = self.resolve_name(name)
spec = self._specs.get(canonical)
if spec is None:
return ToolResult(name=name, ok=False, content=f"Unknown tool: {name}", metadata={"error_type": "unknown_tool"})
handler = spec.handler
try:
params = inspect.signature(handler).parameters
result = handler(arguments, runtime) if runtime is not None and len(params) >= 2 else handler(arguments)
if inspect.isawaitable(result):
return await result
return result
except Exception as exc:
return ToolResult(name=canonical, ok=False, content=str(exc), metadata={"error_type": "exception"})
def _argument_schema_to_json_schema(schema: dict[str, ToolArgumentSchema]) -> dict[str, Any]:
properties: dict[str, Any] = {}
required: list[str] = []
for field_name, field_spec in schema.items():
field_schema = _type_to_json_schema(field_spec.type)
if field_spec.default is not None:
field_schema["default"] = field_spec.default
properties[field_name] = field_schema
if field_spec.required and field_spec.default is None:
required.append(field_name)
payload: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
if required:
payload["required"] = required
return payload
def _type_to_json_schema(tp: type | tuple[type, ...]) -> dict[str, Any]:
if isinstance(tp, tuple):
return {"anyOf": [_type_to_json_schema(item) for item in tp]}
return {str: {"type": "string"}, int: {"type": "integer"}, bool: {"type": "boolean"}, dict: {"type": "object", "additionalProperties": True}, list: {"type": "array", "items": {}}}.get(tp, {"type": "string"})
def build_builtin_registry(workspace_root: str | Path, session_index: Any, adapter_specs: list[ToolSpec] | None = None) -> ToolRegistry:
root = Path(workspace_root).resolve()
def safe_path(raw: Any) -> Path:
path = (root / str(raw)).resolve()
if root not in path.parents and path != root:
raise ValueError(f"Path escapes workspace: {raw}")
return path
def _legacy_virtual_read(raw_path: Any, *, as_json: bool) -> ToolResult | None:
"""Compatibility for paths older prompts/models may hallucinate.
The authoritative session state is .vimax/sessions.json and logs are
.vimax/logs/*.jsonl, but some model turns ask for per-session files like
.working_dir/<session>/session.json or .vimax/logs/<session>.log.
"""
path = safe_path(raw_path)
try:
rel = path.relative_to(root)
except ValueError:
return None
parts = rel.parts
if len(parts) == 3 and parts[0] == ".working_dir" and parts[2] == "session.json":
session_id = parts[1]
record = session_index.get(session_id)
if record is None:
return None
payload = {
"session": record,
"artifact_checklist": session_index.artifact_checklist(session_id),
"source": ".vimax/sessions.json",
"virtual_path": rel.as_posix(),
}
content = json.dumps(payload, ensure_ascii=False, indent=2)
return ToolResult("read_json" if as_json else "read_file", True, content, {"virtual_path": True, "source": ".vimax/sessions.json"})
if len(parts) == 3 and parts[0] == ".vimax" and parts[1] == "logs" and parts[2].endswith(".log"):
session_id = parts[2][:-4]
rows: list[dict[str, Any]] = []
for log_name in ("loop_history", "tool_calls", "revisions"):
log_path = session_index.logs_dir / f"{log_name}.jsonl"
if not log_path.exists():
continue
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
if session_id not in line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
item = {"raw": line}
item["_log"] = log_name
rows.append(item)
payload = {
"session_id": session_id,
"source": ".vimax/logs/*.jsonl",
"virtual_path": rel.as_posix(),
"records": rows,
}
content = json.dumps(payload, ensure_ascii=False, indent=2)
return ToolResult("read_json" if as_json else "read_file", True, content, {"virtual_path": True, "source": ".vimax/logs/*.jsonl", "record_count": len(rows)})
return None
def read_file(args: dict[str, Any]) -> ToolResult:
path = safe_path(args["path"])
if not path.exists():
virtual = _legacy_virtual_read(args["path"], as_json=False)
if virtual is not None:
return virtual
return ToolResult("read_file", False, f"File not found: {path}")
return ToolResult("read_file", True, path.read_text(encoding="utf-8"))
def read_json(args: dict[str, Any]) -> ToolResult:
path = safe_path(args["path"])
if not path.exists():
virtual = _legacy_virtual_read(args["path"], as_json=True)
if virtual is not None:
return virtual
return ToolResult("read_json", False, f"File not found: {path}")
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return ToolResult("read_json", False, f"Invalid JSON: {exc}", {"error_type": "invalid_json"})
return ToolResult("read_json", True, json.dumps(payload, ensure_ascii=False, indent=2))
def write_json(args: dict[str, Any]) -> ToolResult:
path = safe_path(args["path"])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(args["data"], ensure_ascii=False, indent=2), encoding="utf-8")
return ToolResult("write_json", True, f"Wrote JSON {path.relative_to(root)}")
def list_files(args: dict[str, Any]) -> ToolResult:
path = safe_path(args.get("path", "."))
if not path.exists():
return ToolResult("list_files", False, f"Path not found: {path}")
rows = [str(item.relative_to(root)) for item in sorted(path.iterdir())]
return ToolResult("list_files", True, "\n".join(rows) or "No entries")
def glob_files(args: dict[str, Any]) -> ToolResult:
pattern = str(args["pattern"])
matches = [str(Path(item).resolve().relative_to(root)) for item in glob.glob(str(root / pattern), recursive=True)]
return ToolResult("glob_files", True, "\n".join(matches[:200]) or "No matches")
def search_text(args: dict[str, Any]) -> ToolResult:
needle = str(args["query"])
base = safe_path(args.get("path", "."))
rows: list[str] = []
paths = base.rglob("*") if base.is_dir() else [base]
for path in paths:
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
for idx, line in enumerate(text.splitlines(), start=1):
if needle in line:
rows.append(f"{path.relative_to(root)}:{idx}: {line}")
if len(rows) >= int(args.get("max_results", 100)):
return ToolResult("search_text", True, "\n".join(rows))
return ToolResult("search_text", True, "\n".join(rows) or "No matches")
def memory_read(args: dict[str, Any]) -> ToolResult:
return ToolResult("memory_read", True, session_index.memory_text())
def memory_write(args: dict[str, Any]) -> ToolResult:
session_index.write_memory(str(args["content"]))
return ToolResult("memory_write", True, "Updated .vimax/memory.md")
def todo_path() -> Path:
return root / ".vimax" / "todo.json"
def todo_read(args: dict[str, Any]) -> ToolResult:
path = todo_path()
if not path.exists():
return ToolResult("todo_read", True, json.dumps({"items": []}, ensure_ascii=False, indent=2), {"items": []})
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return ToolResult("todo_read", False, f"Invalid todo JSON: {exc}", {"error_type": "invalid_json"})
items = payload.get("items")
if not isinstance(items, list):
return ToolResult("todo_read", False, "Invalid todo JSON: expected an items array", {"error_type": "invalid_todo"})
return ToolResult("todo_read", True, json.dumps({"items": items}, ensure_ascii=False, indent=2), {"items": items})
def todo_write(args: dict[str, Any]) -> ToolResult:
items = args.get("items")
if not isinstance(items, list):
return ToolResult("todo_write", False, "items must be an array", {"error_type": "invalid_arguments"})
normalized: list[dict[str, Any]] = []
for index, item in enumerate(items):
if not isinstance(item, dict):
return ToolResult("todo_write", False, f"items[{index}] must be an object", {"error_type": "invalid_arguments", "index": index})
content = str(item.get("content", "")).strip()
if not content:
return ToolResult("todo_write", False, f"items[{index}].content is required", {"error_type": "invalid_arguments", "index": index})
status = str(item.get("status", "pending")).strip() or "pending"
if status not in {"pending", "in_progress", "completed"}:
return ToolResult("todo_write", False, f"items[{index}].status must be pending, in_progress, or completed", {"error_type": "invalid_arguments", "index": index})
normalized.append({"content": content, "status": status})
path = todo_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"items": normalized}, ensure_ascii=False, indent=2), encoding="utf-8")
return ToolResult("todo_write", True, f"Updated .vimax/todo.json with {len(normalized)} item(s)", {"items": normalized, "item_count": len(normalized)})
async def sleep_tool(args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
seconds = float(args.get("seconds", 0))
if seconds < 0 or seconds > 300:
return ToolResult("sleep", False, "seconds must be between 0 and 300")
if runtime:
runtime.emit_progress(f"Sleeping for {seconds:g}s", stage="running")
await asyncio.sleep(seconds)
return ToolResult("sleep", True, f"Slept for {seconds:g}s")
async def run_shell(args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
if os.environ.get("VIMAX_ENABLE_RUN_SHELL") != "1":
return ToolResult("run_shell", False, "run_shell is disabled by default. Set VIMAX_ENABLE_RUN_SHELL=1 to enable bounded shell commands.", {"error_type": "disabled"})
command = str(args["command"]).strip()
timeout_seconds = min(max(int(args.get("timeout_seconds", 30)), 1), 120)
output_limit = min(max(int(args.get("output_limit", 20000)), 1000), 50000)
denied_tokens = ["rm ", "rm -", "sudo", "chmod", "chown", "mkfs", "dd ", ":(){", "curl ", "wget ", "ssh ", "printenv", "env", "export"]
lowered = command.lower()
if any(token in lowered for token in denied_tokens):
return ToolResult("run_shell", False, "Command rejected by run_shell policy.", {"error_type": "command_rejected"})
if runtime:
runtime.emit_progress("Starting shell command", stage="starting", metadata={"command": command, "timeout_seconds": timeout_seconds})
proc = await asyncio.create_subprocess_shell(command, cwd=root, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return ToolResult("run_shell", False, f"Command timed out after {timeout_seconds}s", {"error_type": "timeout", "timeout_seconds": timeout_seconds})
content = ""
if stdout:
content += stdout.decode(errors="replace")
if stderr:
content += stderr.decode(errors="replace")
truncated = len(content) > output_limit
if truncated:
content = content[:output_limit] + "\n...[truncated]"
return ToolResult("run_shell", proc.returncode == 0, content, {"returncode": proc.returncode, "truncated": truncated})
specs = [
ToolSpec("read_file", "Read a UTF-8 text file inside the workspace. Also resolves virtual legacy session paths like .vimax/logs/<session>.log.", read_file, schema={"path": ToolArgumentSchema(str, True)}, concurrency_safe=True),
ToolSpec("read_json", "Read and parse a JSON file inside the workspace. Also resolves virtual legacy session paths like .working_dir/<session>/session.json.", read_json, schema={"path": ToolArgumentSchema(str, True)}, concurrency_safe=True),
ToolSpec("write_json", "Write formatted JSON inside the workspace.", write_json, schema={"path": ToolArgumentSchema(str, True), "data": ToolArgumentSchema((dict, list), True)}),
ToolSpec("list_files", "List direct children of a workspace path.", list_files, schema={"path": ToolArgumentSchema(str, False, ".")}, concurrency_safe=True),
ToolSpec("glob_files", "Find workspace files with a glob pattern.", glob_files, schema={"pattern": ToolArgumentSchema(str, True)}, concurrency_safe=True),
ToolSpec("search_text", "Search text in workspace files.", search_text, schema={"query": ToolArgumentSchema(str, True), "path": ToolArgumentSchema(str, False, "."), "max_results": ToolArgumentSchema(int, False, 100)}, concurrency_safe=True),
ToolSpec("memory_read", "Read .vimax/memory.md user preferences.", memory_read, schema={}, concurrency_safe=True),
ToolSpec("memory_write", "Replace .vimax/memory.md with user preference notes only.", memory_write, schema={"content": ToolArgumentSchema(str, True)}),
ToolSpec("todo_read", "Read short-term todo items from .vimax/todo.json. This is not a task or team system.", todo_read, schema={}, concurrency_safe=True),
ToolSpec("todo_write", "Replace short-term todo items in .vimax/todo.json. Items require content and may use pending, in_progress, or completed status.", todo_write, schema={"items": ToolArgumentSchema(list, True)}),
ToolSpec("sleep", "Wait for a bounded number of seconds.", sleep_tool, schema={"seconds": ToolArgumentSchema(int, False, 0)}, concurrency_safe=True),
ToolSpec("run_shell", "Run a bounded shell command in the workspace. Disabled unless VIMAX_ENABLE_RUN_SHELL=1; rejects dangerous commands, enforces timeout, and truncates output.", run_shell, schema={"command": ToolArgumentSchema(str, True), "timeout_seconds": ToolArgumentSchema(int, False, 30), "output_limit": ToolArgumentSchema(int, False, 20000)}),
]
for spec in adapter_specs or []:
specs.append(spec)
return ToolRegistry(specs)
+810
View File
@@ -0,0 +1,810 @@
from __future__ import annotations
import asyncio
from datetime import datetime
from contextlib import contextmanager, redirect_stderr, redirect_stdout
import json
import logging
import os
from pathlib import Path
from typing import Any
from langchain.chat_models import init_chat_model
from langchain_openai import OpenAIEmbeddings
from tenacity import RetryError
from interfaces import CharacterInScene
from agents.event_extractor import EventExtractor
from agents.global_information_planner import GlobalInformationPlanner
from agents.novel_compressor import NovelCompressor
from agents.scene_extractor import SceneExtractor
from pipelines.novel2movie_pipeline import Novel2MoviePipeline
from pipelines.idea2video_pipeline import Idea2VideoPipeline
from pipelines.script2video_pipeline import Script2VideoPipeline
from tools.image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI
from tools.reranker_bge_silicon_api import RerankerBgeSiliconapi
from tools.video_generator_openrouter_api import VideoGeneratorOpenRouterAPI
from tools.video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI
from .config import embedding_api_key, embedding_base_url, embedding_model, embedding_model_provider, image_api_key, image_base_url, image_model, llm_api_key, llm_base_url, llm_model, llm_model_provider, reranker_api_key, reranker_base_url, reranker_model, video_api_key, video_base_url, video_model, video_provider
from .models import ToolResult
from .tools import ToolArgumentSchema, ToolRuntimeContext, ToolSpec
class _UnavailableGenerator:
async def generate_single_image(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("Image generator is not available in narrative planning mode")
async def generate_single_video(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("Video generator is not available in narrative planning mode")
def build_vimax_adapter_specs(workspace_root: str | Path, session_index: Any) -> list[ToolSpec]:
adapter = ViMaxAdapters(Path(workspace_root), session_index)
return [
ToolSpec(
name="vimax_narrative_planning",
description=(
"Create or revise ViMax structured text artifacts for the active session. "
"Idea mode writes story, characters, script, and scene-level storyboard/shot_decomposition/camera_tree under idea2video/scene_<idx>/. "
"Script mode writes characters, storyboard, shot_decomposition, and camera_tree under script2video/. "
"For a new video idea or new script, omit session_id or pass the new idea/script; the adapter will create a new session instead of reusing mismatched artifacts. If idea/script/revision_target are omitted and the active session has an idea, continue that session and fill missing structured text artifacts. "
"It does not generate keyframes, video clips, or final video. Call this before revising storyboard/shots when those artifacts do not exist."
),
handler=adapter.vimax_narrative_planning,
schema={
"session_id": ToolArgumentSchema(str, required=False, default=""),
"idea": ToolArgumentSchema(str, required=False, default=""),
"script": ToolArgumentSchema(str, required=False, default=""),
"user_requirement": ToolArgumentSchema(str, required=False, default=""),
"style": ToolArgumentSchema(str, required=False, default=""),
"revision_target": ToolArgumentSchema(str, required=False, default=""),
"revision_instruction": ToolArgumentSchema(str, required=False, default=""),
},
),
ToolSpec(
name="vimax_novel_planning",
description=(
"Create ViMax structured text artifacts from a novel or novel excerpt. "
"This writes novel2video/novel, events, relevant_chunks, scenes, and global_information text artifacts. "
"Use this when the user provides long prose, a novel excerpt, or asks for novel-to-video planning. "
"It does not generate character portraits, scene videos, or final video."
),
handler=adapter.vimax_novel_planning,
schema={
"session_id": ToolArgumentSchema(str, required=False, default=""),
"novel_text": ToolArgumentSchema(str, required=True),
"user_requirement": ToolArgumentSchema(str, required=False, default=""),
"style": ToolArgumentSchema(str, required=False, default=""),
},
),
ToolSpec(
name="vimax_render_video",
description=(
"Render keyframes, video clips, and final video for the active ViMax session. "
"This checks that structured text artifacts exist before rendering and reports missing dependencies instead of pretending render started."
),
handler=adapter.vimax_render_video,
schema={
"session_id": ToolArgumentSchema(str, required=False, default=""),
"mode": ToolArgumentSchema(str, required=False, default="foreground"),
"force": ToolArgumentSchema(bool, required=False, default=False),
},
),
]
class ViMaxAdapters:
def __init__(self, workspace_root: Path, session_index: Any) -> None:
self.workspace_root = workspace_root.resolve()
self.session_index = session_index
async def vimax_narrative_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
idea = str(args.get("idea", "") or "").strip()
script = str(args.get("script", "") or "").strip()
user_requirement = str(args.get("user_requirement", "") or "").strip()
requested_style = str(args.get("style", "") or "").strip()
style = requested_style
session = self._resolve_session(str(args.get("session_id", "") or ""), idea=idea, script=script, user_requirement=user_requirement, style=requested_style)
session_id = session["session_id"]
working_dir = self.session_index.working_dir(session_id)
idea_dir = working_dir / "idea2video"
script_dir = working_dir / "script2video"
idea_dir.mkdir(parents=True, exist_ok=True)
script_dir.mkdir(parents=True, exist_ok=True)
if not idea and not script:
revision_target = str(args.get("revision_target") or "").strip()
if revision_target:
return await self._revise_narrative_artifact(session_id, working_dir, revision_target, str(args.get("revision_instruction") or "").strip(), runtime)
session_idea = str(session.get("idea") or "").strip()
if session_idea:
idea = session_idea
user_requirement = user_requirement or str(session.get("user_requirement") or "").strip()
style = requested_style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9"
else:
return ToolResult("vimax_narrative_planning", False, "Provide `idea`, `script`, a revision target, or an active session with an existing idea for narrative planning.", {"error_type": "missing_input", "session_id": session_id})
style = style or str(session.get("style") or "").strip() or "Cinematic, coherent, 16:9"
self._update_session_metadata(session_id, idea="", user_requirement="", style=style)
try:
self.session_index.update_stage(session_id, "narrative_planning", "Generating structured text artifacts")
if runtime:
runtime.emit_progress("Starting narrative planning", stage="starting", metadata={"session_id": session_id})
await asyncio.sleep(0)
generated_before = self.session_index.artifact_checklist(session_id)
if runtime:
runtime.emit_progress("Initializing bounded chat model", stage="initializing_llm", metadata={"session_id": session_id, "timeout_seconds": _llm_request_timeout_seconds(), "max_tokens": _narrative_max_tokens()})
await asyncio.sleep(0)
chat_model = _build_chat_model()
if runtime:
runtime.emit_progress("Bounded chat model initialized", stage="chat_model_ready", metadata={"session_id": session_id})
await asyncio.sleep(0)
dummy = _UnavailableGenerator()
# Do not globally redirect stdout/stderr while the JSONL CLI is streaming events.
# The adapter exposes pipeline progress through explicit tool_progress events instead.
if idea:
idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(idea_dir))
if runtime:
runtime.emit_progress("Idea pipeline initialized", stage="idea_pipeline_ready", metadata={"session_id": session_id})
await asyncio.sleep(0)
story = await _run_planning_step(
"Developing story from user idea",
"develop_story",
idea_pipeline.develop_story(idea=idea, user_requirement=user_requirement, quiet=True),
runtime,
{"session_id": session_id},
)
characters = await _run_planning_step(
"Extracting characters from story",
"extract_characters",
idea_pipeline.extract_characters(story=story, quiet=True),
runtime,
{"session_id": session_id},
)
scene_scripts = await _run_planning_step(
"Writing scene scripts from story",
"write_script",
idea_pipeline.write_script_based_on_story(story=story, user_requirement=user_requirement, quiet=True),
runtime,
{"session_id": session_id},
)
for idx, scene_script in enumerate(scene_scripts if isinstance(scene_scripts, list) else [scene_scripts]):
scene_dir = idea_dir / f"scene_{idx}"
scene_text = scene_script if isinstance(scene_script, str) else json.dumps(scene_script, ensure_ascii=False, indent=2)
script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(scene_dir))
await _run_planning_step(
f"Planning scene {idx} storyboard and shots",
"plan_scene",
script_pipeline.plan_text_artifacts(script=scene_text, user_requirement=user_requirement, style=style, characters=characters, progress=_pipeline_progress(runtime, session_id, scene_index=idx), quiet=True),
runtime,
{"session_id": session_id, "scene_index": idx},
)
else:
(script_dir / "script.txt").write_text(script, encoding="utf-8")
script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=dummy, video_generator=dummy, working_dir=str(script_dir))
if runtime:
runtime.emit_progress("Script pipeline initialized", stage="script_pipeline_ready", metadata={"session_id": session_id})
await asyncio.sleep(0)
await _run_planning_step(
"Planning storyboard and shots from provided script",
"plan_script",
script_pipeline.plan_text_artifacts(script=script, user_requirement=user_requirement, style=style, progress=_pipeline_progress(runtime, session_id), quiet=True),
runtime,
{"session_id": session_id},
)
except Exception as exc:
self.session_index.update_stage(session_id, "error", f"Narrative planning failed: {exc}")
checklist = self.session_index.artifact_checklist(session_id)
payload = {
"session_id": session_id,
"working_dir": str(working_dir.relative_to(self.workspace_root)),
"error_type": "recoverable_planning_step_failed",
"retryable": True,
"error": str(exc),
"present": [path for path, present in checklist.items() if present],
"missing": [path for path, present in checklist.items() if not present],
}
if runtime:
runtime.emit_progress("Narrative planning failed; partial artifacts were kept", stage="planning_failed", metadata=payload)
return ToolResult("vimax_narrative_planning", False, f"Narrative planning failed: {exc}", payload)
checklist = self.session_index.artifact_checklist(session_id)
generated = [path for path, present in checklist.items() if present and not generated_before.get(path)]
reused = [path for path, present in checklist.items() if present and generated_before.get(path)]
ready_for_render = _ready_for_render(checklist)
self.session_index.update_stage(session_id, "narrative_planned", "Structured text planning complete" if ready_for_render else "Structured text planning partially complete")
if runtime:
runtime.emit_progress("Narrative planning complete", stage="completed", metadata={"ready_for_render": ready_for_render})
payload = {
"session_id": session_id,
"working_dir": str(working_dir.relative_to(self.workspace_root)),
"generated": generated,
"reused": reused,
"missing": [path for path, present in checklist.items() if not present],
"ready_for_render": ready_for_render,
}
return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
async def _revise_narrative_artifact(self, session_id: str, working_dir: Path, revision_target: str, revision_instruction: str, runtime: ToolRuntimeContext | None = None) -> ToolResult:
if not revision_instruction:
self.session_index.update_stage(session_id, "error", "Revision failed: missing revision_instruction")
return ToolResult("vimax_narrative_planning", False, "revision_instruction is required when revision_target is provided.", {"error_type": "missing_revision_instruction", "session_id": session_id, "revision_target": revision_target})
try:
target_path = _resolve_artifact_path(working_dir, revision_target)
except ValueError as exc:
self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}")
return ToolResult("vimax_narrative_planning", False, str(exc), {"error_type": "invalid_revision_target", "session_id": session_id, "revision_target": revision_target})
if not target_path.exists():
self.session_index.update_stage(session_id, "error", f"Revision failed: target does not exist: {revision_target}")
return ToolResult("vimax_narrative_planning", False, f"Revision target does not exist: {revision_target}", {"error_type": "dependency_missing", "session_id": session_id, "revision_target": revision_target})
try:
self.session_index.update_stage(session_id, "narrative_planning", "Revising structured text artifact")
if runtime:
runtime.emit_progress("Revising structured text artifact", stage="revising", metadata={"session_id": session_id, "revision_target": revision_target})
chat_model = _build_chat_model()
before = target_path.read_text(encoding="utf-8")
revised = await _revise_artifact_with_llm(chat_model, target_path.relative_to(working_dir).as_posix(), before, revision_instruction)
if target_path.suffix == ".json":
try:
revised_payload = json.loads(revised)
except json.JSONDecodeError as exc:
self.session_index.update_stage(session_id, "error", f"Revision failed: invalid JSON output: {exc}")
return ToolResult("vimax_narrative_planning", False, f"Revision output was not valid JSON: {exc}", {"error_type": "invalid_revision_json", "session_id": session_id, "revision_target": revision_target})
revised = json.dumps(revised_payload, ensure_ascii=False, indent=2)
target_path.write_text(revised, encoding="utf-8")
except Exception as exc:
self.session_index.update_stage(session_id, "error", f"Revision failed: {exc}")
raise
stale = _stale_keys_for_revision(target_path.relative_to(working_dir).as_posix())
if stale:
self.session_index.mark_stale(session_id, stale)
self.session_index.append_log("revisions", {"session_id": session_id, "target": target_path.relative_to(working_dir).as_posix(), "instruction": revision_instruction, "stale": stale, "before_preview": before[:500], "after_preview": revised[:500]})
checklist = self.session_index.artifact_checklist(session_id)
ready_for_render = _ready_for_render(checklist)
self.session_index.update_stage(session_id, "narrative_planned" if ready_for_render else "narrative_planning", "Revised structured text artifact")
payload = {
"session_id": session_id,
"working_dir": str(working_dir.relative_to(self.workspace_root)),
"generated": [],
"reused": [path for path, present in checklist.items() if present],
"revised": [target_path.relative_to(working_dir).as_posix()],
"missing": [path for path, present in checklist.items() if not present],
"stale": stale,
"ready_for_render": ready_for_render,
"revision_target": target_path.relative_to(working_dir).as_posix(),
}
return ToolResult("vimax_narrative_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
async def vimax_novel_planning(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
novel_text = str(args.get("novel_text", "") or "").strip()
user_requirement = str(args.get("user_requirement", "") or "").strip()
style = str(args.get("style", "") or "").strip() or "Cinematic, coherent, 16:9"
if not novel_text:
return ToolResult("vimax_novel_planning", False, "novel_text is required for novel planning.", {"error_type": "missing_input"})
session_id_arg = str(args.get("session_id", "") or "").strip()
session = self.session_index.create(idea=novel_text, user_requirement=user_requirement, style=style, session_id=session_id_arg or None)
session_id = session["session_id"]
working_dir = self.session_index.working_dir(session_id)
novel_dir = working_dir / "novel2video"
novel_dir.mkdir(parents=True, exist_ok=True)
generated_before = self.session_index.artifact_checklist(session_id)
try:
self.session_index.update_stage(session_id, "novel_planning", "Generating novel structured text artifacts")
if runtime:
runtime.emit_progress("Starting novel planning", stage="starting", metadata={"session_id": session_id})
await asyncio.sleep(0)
pipeline = _build_novel_pipeline(novel_dir)
await _run_planning_step(
"Planning novel structured text artifacts",
"novel_plan_text_artifacts",
pipeline.plan_text_artifacts(
novel_text=novel_text,
user_requirement=user_requirement,
style=style,
progress=_pipeline_progress(runtime, session_id),
quiet=True,
),
runtime,
{"session_id": session_id},
)
except Exception as exc:
self.session_index.update_stage(session_id, "error", f"Novel planning failed: {exc}")
return ToolResult("vimax_novel_planning", False, str(exc), {"error_type": "exception", "session_id": session_id})
checklist = self.session_index.artifact_checklist(session_id)
generated = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and not generated_before.get(path)]
reused = [path for path, present in checklist.items() if path.startswith("novel2video/") and present and generated_before.get(path)]
missing = [path for path, present in checklist.items() if path.startswith("novel2video/") and not present]
ready = _novel_text_ready(checklist)
self.session_index.update_stage(session_id, "novel_planned" if ready else "novel_planning", "Novel structured text planning complete" if ready else "Novel structured text planning partially complete")
if runtime:
runtime.emit_progress("Novel planning complete", stage="completed", metadata={"session_id": session_id, "ready_for_scene_render": False})
payload = {
"session_id": session_id,
"working_dir": str(working_dir.relative_to(self.workspace_root)),
"generated": generated,
"reused": reused,
"missing": missing,
"ready_for_scene_render": False,
}
return ToolResult("vimax_novel_planning", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
async def vimax_render_video(self, args: dict[str, Any], runtime: ToolRuntimeContext | None = None) -> ToolResult:
session_id = str(args.get("session_id", "") or "").strip()
session = self.session_index.get(session_id) if session_id else self.session_index.active()
if session is None:
return ToolResult("vimax_render_video", False, "No active session to render.", {"error_type": "missing_session"})
session_id = session["session_id"]
checklist = self.session_index.artifact_checklist(session_id)
missing = _missing_render_dependencies(checklist)
working_dir = self.session_index.working_dir(session_id)
if missing:
payload = {"error_type": "dependency_missing", "missing": missing, "session_id": session_id}
_write_render_status(working_dir, status="dependency_missing", payload=payload)
return ToolResult("vimax_render_video", False, f"Dependency missing: {', '.join(missing)}", payload)
self.session_index.update_stage(session_id, "rendering", "Rendering video artifacts")
_write_render_status(working_dir, status="rendering", payload={"session_id": session_id, "render_started": True, "render_completed": False})
try:
chat_model = _build_chat_model()
image_generator = _build_image_generator()
video_generator = _build_video_generator()
if runtime:
runtime.emit_progress("Starting video render", stage="rendering", metadata={"session_id": session_id})
if _idea_mode_ready(checklist):
idea_pipeline = Idea2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "idea2video"))
with _suppress_pipeline_output():
final_video = await idea_pipeline(idea=str(session.get("idea", "")), user_requirement=str(session.get("user_requirement", "")), style=str(session.get("style", "")), quiet=True)
self.session_index.update_stage(session_id, "rendered", "Final video rendered")
payload = {"session_id": session_id, "render_mode": "idea2video", "render_started": True, "render_completed": True, "final_video_path": str(Path(final_video).relative_to(self.workspace_root)), "missing": []}
_write_render_status(working_dir, status="rendered", payload=payload)
return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
if _script_mode_ready(checklist):
script_dir = working_dir / "script2video"
script_text = _load_script_text(working_dir)
characters = _load_characters(script_dir / "characters.json")
pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(script_dir))
with _suppress_pipeline_output():
final_video = await pipeline(script=script_text, user_requirement=str(session.get("user_requirement", "")), style=str(session.get("style", "")), characters=characters, quiet=True, progress=_pipeline_progress(runtime, session_id))
self.session_index.update_stage(session_id, "rendered", "Final video rendered")
payload = {"session_id": session_id, "render_mode": "script2video", "render_started": True, "render_completed": True, "final_video_path": str(Path(final_video).relative_to(self.workspace_root)), "missing": []}
_write_render_status(working_dir, status="rendered", payload=payload)
return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
if _novel_mode_ready(checklist):
novel_dir = working_dir / "novel2video"
pipeline = _build_novel_render_pipeline(novel_dir, chat_model, image_generator, video_generator)
with _suppress_pipeline_output():
render_result = await pipeline.render_video_artifacts(style=str(session.get("style", "")), user_requirement=str(session.get("user_requirement", "")), quiet=True, progress=_pipeline_progress(runtime, session_id))
scene_videos_dir = Path(render_result["scene_videos_dir"])
self.session_index.update_stage(session_id, "novel_scene_rendered", "Novel scene videos rendered")
payload = {
"session_id": session_id,
"render_mode": "novel2video",
"render_started": True,
"render_completed": True,
"scene_render_completed": True,
"final_video_path": None,
"scene_videos_dir": str(scene_videos_dir.relative_to(self.workspace_root)),
"scene_video_dirs": [str(Path(path).relative_to(self.workspace_root)) for path in render_result.get("scene_video_dirs", [])],
"scene_count": render_result.get("scene_count", 0),
"missing": [],
}
_write_render_status(working_dir, status="rendered", payload=payload)
return ToolResult("vimax_render_video", True, json.dumps(payload, ensure_ascii=False, indent=2), payload)
except Exception as exc:
unwrapped = _unwrap_retry_error(exc)
error_text = _sanitize_error_text(str(unwrapped))
wrapped_error_text = _sanitize_error_text(str(exc))
self.session_index.update_stage(session_id, "error", f"Render failed: {error_text}")
checklist = self.session_index.artifact_checklist(session_id)
payload = {
"error_type": "render_failed",
"retryable": _is_retryable_render_error(unwrapped),
"session_id": session_id,
"error": error_text,
"wrapped_error": wrapped_error_text,
"present": [path for path, present in checklist.items() if present],
"missing": [path for path, present in checklist.items() if not present],
}
_write_render_status(working_dir, status="error", payload=payload)
if runtime:
runtime.emit_progress("Render failed; partial artifacts were kept", stage="render_failed", metadata=payload)
return ToolResult("vimax_render_video", False, f"Render failed: {error_text}", payload)
payload = {"error_type": "dependency_missing", "session_id": session_id}
_write_render_status(working_dir, status="dependency_missing", payload=payload)
return ToolResult("vimax_render_video", False, "No render mode matched current session.", payload)
def _resolve_session(self, session_id: str, *, idea: str, script: str, user_requirement: str, style: str) -> dict[str, Any]:
requested_source = idea or script
if session_id:
session = self.session_index.get(session_id)
if session is None:
session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style, session_id=session_id)
elif requested_source and _is_new_source_for_session(session, requested_source):
session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
else:
self.session_index.set_active(session_id)
else:
if requested_source:
session = self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
else:
session = self.session_index.active() or self.session_index.create(idea=requested_source, user_requirement=user_requirement, style=style)
self._update_session_metadata(session["session_id"], idea=requested_source, user_requirement=user_requirement, style=style)
return self.session_index.get(session["session_id"]) or session
def _update_session_metadata(self, session_id: str, *, idea: str, user_requirement: str, style: str) -> None:
data = self.session_index.load()
record = data.get("sessions", {}).get(session_id)
if not isinstance(record, dict):
return
if idea and not record.get("idea"):
record["idea"] = idea
if user_requirement:
record["user_requirement"] = user_requirement
if style:
record["style"] = style
self.session_index.save(data)
class _DiscardStream:
def write(self, text: str) -> int:
return len(text)
def flush(self) -> None:
pass
_PIPELINE_OUTPUT_SINK = _DiscardStream()
@contextmanager
def _suppress_pipeline_output():
previous_disable_level = logging.root.manager.disable
logging.disable(logging.WARNING)
try:
with redirect_stdout(_PIPELINE_OUTPUT_SINK), redirect_stderr(_PIPELINE_OUTPUT_SINK):
yield
finally:
logging.disable(previous_disable_level)
def _narrative_step_timeout_seconds() -> float:
raw = os.environ.get("VIMAX_NARRATIVE_STEP_TIMEOUT_SECONDS", "900")
try:
return max(0.0, float(raw))
except ValueError:
return 900.0
async def _run_planning_step(
message: str,
stage: str,
awaitable: Any,
runtime: ToolRuntimeContext | None,
metadata: dict[str, Any] | None = None,
) -> Any:
timeout_seconds = _narrative_step_timeout_seconds()
event_metadata = dict(metadata or {})
event_metadata["timeout_seconds"] = timeout_seconds
if runtime:
runtime.emit_progress(message, stage=stage, metadata=event_metadata)
await asyncio.sleep(0)
try:
with _suppress_pipeline_output():
if timeout_seconds <= 0:
return await awaitable
return await asyncio.wait_for(awaitable, timeout=timeout_seconds)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"{message} timed out after {timeout_seconds:g}s") from exc
except Exception as exc:
raise RuntimeError(f"{message} failed: {exc}") from exc
def _is_new_source_for_session(session: dict[str, Any], requested_source: str) -> bool:
current = str(session.get("idea") or "").strip()
requested = requested_source.strip()
if not current or not requested:
return False
return current != requested
def _llm_request_timeout_seconds() -> float:
raw = os.environ.get("VIMAX_LLM_REQUEST_TIMEOUT_SECONDS", "300")
try:
return max(1.0, float(raw))
except ValueError:
return 300.0
def _narrative_max_tokens() -> int:
raw = os.environ.get("VIMAX_NARRATIVE_MAX_TOKENS", "4096")
try:
return max(256, int(raw))
except ValueError:
return 4096
def _pipeline_progress(runtime: ToolRuntimeContext | None, session_id: str, *, scene_index: int | None = None):
if runtime is None:
return None
def emit(stage: str, message: str, metadata: dict[str, Any] | None = None) -> None:
payload = dict(metadata or {})
payload["session_id"] = session_id
if scene_index is not None:
payload["scene_index"] = scene_index
runtime.emit_progress(message, stage=stage, metadata=payload)
return emit
def _build_chat_model() -> Any:
api_key = llm_api_key()
if not api_key:
raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for narrative planning")
return init_chat_model(
model=llm_model(),
model_provider=llm_model_provider(),
api_key=api_key,
base_url=llm_base_url(),
timeout=_llm_request_timeout_seconds(),
max_retries=0,
max_completion_tokens=_narrative_max_tokens(),
)
def _build_image_generator() -> ImageGeneratorNanobananaYunwuAPI:
api_key = image_api_key()
if not api_key:
raise RuntimeError("VIMAX_IMAGE_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml image/llm api_key is required for image generation")
return ImageGeneratorNanobananaYunwuAPI(api_key=api_key, model=image_model(), base_url=image_base_url())
def _build_video_generator() -> VideoGeneratorVeoYunwuAPI | VideoGeneratorOpenRouterAPI:
api_key = video_api_key()
if not api_key:
raise RuntimeError("VIMAX_VIDEO_API_KEY, VIMAX_LLM_API_KEY, or configs/agent.local.yaml video/llm api_key is required for video generation")
model = video_model()
base_url = video_base_url()
provider = video_provider().strip().lower()
if provider == "openrouter":
return VideoGeneratorOpenRouterAPI(api_key=api_key, model=model, base_url=base_url)
if provider == "yunwu":
return VideoGeneratorVeoYunwuAPI(api_key=api_key, t2v_model=model, ff2v_model=model, base_url=base_url)
raise RuntimeError(f"Unsupported video base_url for automatic provider matching: {base_url}")
class _IdentityRewriter:
async def __call__(self, prompt: str) -> str:
return prompt
def _build_embedding_model() -> Any:
api_key = embedding_api_key()
base_url = embedding_base_url()
provider = embedding_model_provider().strip().lower()
if not api_key or not base_url:
raise RuntimeError("VIMAX_EMBEDDING_API_KEY or configs/agent.local.yaml embedding api_key/base_url is required for novel planning")
if provider != "openai":
raise RuntimeError(f"Unsupported embedding model_provider: {provider}")
return OpenAIEmbeddings(model=embedding_model(), api_key=api_key, base_url=base_url)
def _build_reranker() -> RerankerBgeSiliconapi:
api_key = reranker_api_key()
base_url = reranker_base_url()
if not api_key or not base_url:
raise RuntimeError("VIMAX_RERANKER_API_KEY or configs/agent.local.yaml reranker api_key/base_url is required for novel planning")
return RerankerBgeSiliconapi(api_key=api_key, base_url=base_url, model=reranker_model())
def _build_novel_pipeline(working_dir: Path) -> Novel2MoviePipeline:
api_key = llm_api_key()
if not api_key:
raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel planning")
base_url = llm_base_url()
model = llm_model()
dummy = _UnavailableGenerator()
return Novel2MoviePipeline(
novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model),
event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model),
embeddings=_build_embedding_model(),
rerank_model=_build_reranker(),
scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model),
global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model),
image_generator=dummy,
rewriter=_IdentityRewriter(),
script2video_pipeline=dummy,
working_dir=str(working_dir),
)
def _build_novel_render_pipeline(working_dir: Path, chat_model: Any, image_generator: Any, video_generator: Any) -> Novel2MoviePipeline:
api_key = llm_api_key()
if not api_key:
raise RuntimeError("VIMAX_LLM_API_KEY or configs/agent.local.yaml llm.api_key is required for novel rendering")
base_url = llm_base_url()
model = llm_model()
script_pipeline = Script2VideoPipeline(chat_model=chat_model, image_generator=image_generator, video_generator=video_generator, working_dir=str(working_dir / "videos"))
return Novel2MoviePipeline(
novel_compressor=NovelCompressor(api_key=api_key, base_url=base_url, chat_model=model),
event_extractor=EventExtractor(api_key=api_key, base_url=base_url, chat_model=model),
embeddings=_build_embedding_model(),
rerank_model=_build_reranker(),
scene_extractor=SceneExtractor(api_key=api_key, base_url=base_url, chat_model=model),
global_information_planner=GlobalInformationPlanner(api_key=api_key, base_url=base_url, chat_model=model),
image_generator=image_generator,
rewriter=_IdentityRewriter(),
script2video_pipeline=script_pipeline,
working_dir=str(working_dir),
)
def _unwrap_retry_error(exc: Exception) -> Exception:
if isinstance(exc, RetryError):
try:
return exc.last_attempt.exception() or exc
except Exception:
return exc
return exc
def _is_retryable_render_error(exc: Exception) -> bool:
text = str(exc).lower()
if isinstance(exc, AttributeError):
return False
if "http 403" in text or "key limit exceeded" in text or "quota" in text:
return False
return True
def _sanitize_error_text(text: str) -> str:
sanitized = text
for marker in ("workspaces/default/keys/",):
if marker in sanitized:
prefix, rest = sanitized.split(marker, 1)
key_id = []
for char in rest:
if char.isalnum() or char in "-_":
key_id.append(char)
continue
break
sanitized = prefix + marker + "<redacted>" + rest[len(key_id):]
if "sk-" in sanitized:
prefix, rest = sanitized.split("sk-", 1)
token = []
for char in rest:
if char.isalnum() or char in "-_":
token.append(char)
continue
break
sanitized = prefix + "sk-<redacted>" + rest[len(token):]
return sanitized
def _write_render_status(working_dir: Path, *, status: str, payload: dict[str, Any]) -> None:
working_dir.mkdir(parents=True, exist_ok=True)
event = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"status": status,
**payload,
}
(working_dir / "render_status.json").write_text(json.dumps(event, ensure_ascii=False, indent=2), encoding="utf-8")
with (working_dir / "render_events.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, ensure_ascii=False) + "\n")
def _write_characters_if_missing(path: Path, characters: list[CharacterInScene]) -> None:
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps([character.model_dump() for character in characters], ensure_ascii=False, indent=2), encoding="utf-8")
def _load_characters(path: Path) -> list[CharacterInScene]:
return [CharacterInScene.model_validate(item) for item in json.loads(path.read_text(encoding="utf-8"))]
def _load_script_text(working_dir: Path) -> str:
script_text = working_dir / "script2video" / "script.txt"
if script_text.exists():
return script_text.read_text(encoding="utf-8")
idea_script = working_dir / "idea2video" / "script.json"
if idea_script.exists():
payload = json.loads(idea_script.read_text(encoding="utf-8"))
return json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload
story = working_dir / "idea2video" / "story.txt"
if story.exists():
return story.read_text(encoding="utf-8")
return ""
def _resolve_artifact_path(working_dir: Path, revision_target: str) -> Path:
rel = Path(revision_target)
if rel.is_absolute():
raise ValueError(f"revision_target must be relative to session working_dir: {revision_target}")
path = (working_dir / rel).resolve()
if path != working_dir and working_dir not in path.parents:
raise ValueError(f"revision_target escapes session working_dir: {revision_target}")
return path
async def _revise_artifact_with_llm(chat_model: Any, target: str, current_text: str, instruction: str) -> str:
prompt = (
"Revise this ViMax structured artifact exactly as requested. "
"Return only the complete replacement file content, with no Markdown fences or explanation. "
"If the file is JSON, preserve valid JSON and the existing schema shape.\n\n"
f"Target: {target}\n"
f"Revision instruction: {instruction}\n\n"
"Current file content:\n"
f"{current_text}"
)
if hasattr(chat_model, "ainvoke"):
response = await chat_model.ainvoke(prompt)
elif hasattr(chat_model, "invoke"):
response = chat_model.invoke(prompt)
else:
raise RuntimeError("chat_model does not support invoke/ainvoke for revision mode")
content = getattr(response, "content", response)
if isinstance(content, list):
content = "".join(str(item.get("text", item)) if isinstance(item, dict) else str(item) for item in content)
return _strip_markdown_fences(str(content).strip())
def _strip_markdown_fences(text: str) -> str:
if not text.startswith("```"):
return text
lines = text.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
return "\n".join(lines).strip()
def _stale_keys_for_revision(target: str) -> list[str]:
if "storyboard.json" in target:
return ["shot_descriptions", "camera_tree", "frames", "clips", "final_video"]
if "shot_description.json" in target:
return ["frames", "clips", "final_video"]
if "camera_tree.json" in target:
return ["frames", "clips", "final_video"]
if target.endswith("script.json") or target.endswith("story.txt"):
return ["storyboard", "shot_descriptions", "camera_tree", "frames", "clips", "final_video"]
if target.endswith("characters.json"):
return ["storyboard", "shot_descriptions", "frames", "clips", "final_video"]
return ["frames", "clips", "final_video"]
def _ready_for_render(checklist: dict[str, bool]) -> bool:
return _idea_mode_ready(checklist) or _script_mode_ready(checklist) or _novel_mode_ready(checklist)
def _missing_render_dependencies(checklist: dict[str, bool]) -> list[str]:
if _ready_for_render(checklist):
return []
idea_required = ["idea2video/story.txt", "idea2video/characters.json", "idea2video/script.json", "idea2video/scene_*/storyboard.json", "idea2video/scene_*/shots/*/shot_description.json", "idea2video/scene_*/camera_tree.json"]
script_required = ["script2video/script.txt", "script2video/characters.json", "script2video/storyboard.json", "script2video/shots/*/shot_description.json", "script2video/camera_tree.json"]
novel_required = ["novel2video/novel/novel_compressed.txt", "novel2video/events/event_*.json", "novel2video/relevant_chunks/event_*", "novel2video/scenes/event_*/scene_*.json", "novel2video/global_information/characters/event_level/*.json", "novel2video/global_information/characters/novel_level/*.json"]
return [f"idea mode: {path}" for path in idea_required if not checklist.get(path)] + [f"script mode: {path}" for path in script_required if not checklist.get(path)] + [f"novel mode: {path}" for path in novel_required if not checklist.get(path)]
def _idea_mode_ready(checklist: dict[str, bool]) -> bool:
return bool(checklist.get("idea2video/story.txt") and checklist.get("idea2video/characters.json") and checklist.get("idea2video/script.json") and checklist.get("idea2video/scene_*/storyboard.json") and checklist.get("idea2video/scene_*/shots/*/shot_description.json") and checklist.get("idea2video/scene_*/camera_tree.json"))
def _novel_text_ready(checklist: dict[str, bool]) -> bool:
return _novel_mode_ready(checklist)
def _novel_mode_ready(checklist: dict[str, bool]) -> bool:
return bool(checklist.get("novel2video/novel/novel_compressed.txt") and checklist.get("novel2video/events/event_*.json") and checklist.get("novel2video/relevant_chunks/event_*") and checklist.get("novel2video/scenes/event_*/scene_*.json") and checklist.get("novel2video/global_information/characters/event_level/*.json") and checklist.get("novel2video/global_information/characters/novel_level/*.json"))
def _script_mode_ready(checklist: dict[str, bool]) -> bool:
return bool(checklist.get("script2video/script.txt") and checklist.get("script2video/characters.json") and checklist.get("script2video/storyboard.json") and checklist.get("script2video/shots/*/shot_description.json") and checklist.get("script2video/camera_tree.json"))