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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
ENV/
env/
.env

# IDE
.idea/
.vscode/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Local Docs (Do not commit)
/docs/
31 changes: 31 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[project]
name = "linkwork-agent-sdk"
version = "0.1.0"
description = "LinkWork Agent SDK"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.0",
"redis>=5.0",
"PyYAML>=6.0",
"claude-agent-sdk>=0.1.21",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
]

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]

5 changes: 5 additions & 0 deletions src/linkwork_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""LinkWork Agent SDK package."""

from .engine.agent_engine import AgentEngine

__all__ = ["AgentEngine"]
17 changes: 17 additions & 0 deletions src/linkwork_agent_sdk/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Config package."""

from .loader import ConfigLoader
from .models import (
AgentConfig,
ClaudeSettingsConfig,
LinkWorkAgentSDKConfig,
SystemPromptConfig,
)

__all__ = [
"AgentConfig",
"ClaudeSettingsConfig",
"ConfigLoader",
"LinkWorkAgentSDKConfig",
"SystemPromptConfig",
]
72 changes: 72 additions & 0 deletions src/linkwork_agent_sdk/config/loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Config loader for local JSON file."""

from __future__ import annotations

import json
from pathlib import Path

from pydantic import ValidationError

from ..exceptions import (
ConfigNotFoundError,
ConfigNullError,
ConfigParseError,
ConfigPermissionError,
ConfigValidationError,
)
from .models import LinkWorkAgentSDKConfig


class ConfigLoader:
"""Load and validate SDK config from local JSON file."""

def __init__(self, config_file: str | Path) -> None:
self._config_file = Path(config_file)
self._config: LinkWorkAgentSDKConfig | None = None

@property
def config_file(self) -> Path:
return self._config_file

@property
def config(self) -> LinkWorkAgentSDKConfig:
if self._config is None:
raise ConfigValidationError("Config not loaded")
return self._config

def load(self) -> LinkWorkAgentSDKConfig:
"""Load JSON config and validate with Pydantic."""
if not self._config_file.exists():
raise ConfigNotFoundError(f"Config file not found: {self._config_file}")
if not self._config_file.is_file():
raise ConfigNotFoundError(f"Config path is not file: {self._config_file}")

try:
content = self._config_file.read_text(encoding="utf-8-sig")
except PermissionError as error:
raise ConfigPermissionError(
f"Config file permission denied: {self._config_file}",
) from error
except OSError as error:
raise ConfigParseError(f"Config file read failed: {error}") from error

if not content.strip():
raise ConfigNullError(f"Config file is empty: {self._config_file}")

try:
raw_config = json.loads(content)
except json.JSONDecodeError as error:
raise ConfigParseError(
f"Config JSON parse failed at line {error.lineno}, col {error.colno}: {error.msg}",
) from error

try:
self._config = LinkWorkAgentSDKConfig.model_validate(raw_config)
except ValidationError as error:
details = "; ".join(
f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}"
for item in error.errors()
)
raise ConfigValidationError(f"Config validation failed: {details}") from error

return self._config
55 changes: 55 additions & 0 deletions src/linkwork_agent_sdk/config/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Config models for LinkWork Agent SDK."""

from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, StringConstraints
from typing_extensions import Annotated

WorkerName = Annotated[str, StringConstraints(pattern=r"^[a-z0-9-]+$")]


class ClaudeSettingsConfig(BaseModel):
"""Claude runtime settings."""

model_config = ConfigDict(extra="forbid", strict=True)

env: dict[str, str] = Field(default_factory=dict)
model: str = "openrouter/anthropic/claude-sonnet-4.5"
language: str = "Chinese"


class AgentConfig(BaseModel):
"""Agent behavior settings."""

model_config = ConfigDict(extra="forbid", strict=True)

name: WorkerName = "demo-worker"
max_turns: int = Field(default=100, ge=1)
max_thinking_tokens: int = Field(default=10_000, ge=0)
permission_mode: Literal["default", "acceptEdits", "bypassPermissions"] = "default"
allowed_tools: list[str] = Field(default_factory=list)
disallowed_tools: list[str] = Field(default_factory=list)
can_use_tools: list[str] = Field(default_factory=list)
zz_enabled: bool = Field(default=False, description="启用 zz 安全执行代理(Bash 命令经 zzd 安全审计)")


class SystemPromptConfig(BaseModel):
"""System prompt settings."""

model_config = ConfigDict(extra="forbid", strict=True)

use_preset: bool = True
preset: Literal["claude_code"] = "claude_code"
append: str = ""


class LinkWorkAgentSDKConfig(BaseModel):
"""Top-level SDK config."""

model_config = ConfigDict(extra="forbid", strict=True)

claude_settings: ClaudeSettingsConfig = Field(default_factory=ClaudeSettingsConfig)
agent: AgentConfig = Field(default_factory=AgentConfig)
system_prompt: SystemPromptConfig = Field(default_factory=SystemPromptConfig)
100 changes: 100 additions & 0 deletions src/linkwork_agent_sdk/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""LinkWork Agent SDK constants."""

from __future__ import annotations

import os

SKILLS_DIR = "/opt/agent/skills/"
MCP_CONFIG_FILE = "/opt/agent/mcp.json"
SECURITY_RULES_FILE = "/opt/agent/security.json"
LOG_FALLBACK_DIR = "/workspace/task-logs/"
WORKER_LOG_FALLBACK_DIR = "/workspace/worker-logs/"

REDIS_URL_DEFAULT = "redis://redis:6379"
BLPOP_TIMEOUT_SECONDS = 5
IDLE_TIMEOUT_SECONDS = 600
TASK_RUNTIME_IDLE_TIMEOUT_SECONDS = 600
LOG_RETENTION_DAYS = 7
SECURITY_CHECK_TIMEOUT_SECONDS = 2

TASK_QUEUE_KEY_TEMPLATE = "workstation:{workstation_id}:tasks"
CONTROL_QUEUE_KEY_TEMPLATE = "workstation:{workstation_id}:control"
LOG_STREAM_KEY_TEMPLATE = "logs:{workstation_id}:{task_id}"

WORKSPACE_LOGS_ROOT = "/workspace/logs"
WORKSPACE_USER_ROOT = "/workspace/user"
WORKSPACE_WORKSTATION_ROOT = "/workspace/workstation"
OSS_INPUT_PATH_TEMPLATE = "tasks/{task_id}/input"
OSS_OUTPUT_REPORT_PATH_TEMPLATE = "logs/{user_id}/{task_id}"

WORKSPACE_LOGS_PATH = "/workspace/logs"
DOC_ROOT_PATH = "/workspace"
DOC_USER_PATH = "/workspace/user"
DOC_JOB_PATH = "/workspace/workstation"

ZZ_ACTION_FS_PREPARE = "fs_prepare"
ZZ_ACTION_FS_CLEANUP = "fs_cleanup"

ENV_WORKSTATION_ID = "WORKSTATION_ID"
ENV_TASK_ID = "TASK_ID"
ENV_USER_ID = "USER_ID"
ENV_REDIS_URL = "REDIS_URL"
ENV_IDLE_TIMEOUT = "IDLE_TIMEOUT"
ENV_TASK_RUNTIME_IDLE_TIMEOUT = "TASK_RUNTIME_IDLE_TIMEOUT"
ENV_WORKER_DESTROY_API_BASE = "WORKER_DESTROY_API_BASE"
ENV_WORKER_DESTROY_API_PASSWORD = "WORKER_DESTROY_API_PASSWORD"
ENV_POD_NAME = "POD_NAME"
ENV_SERVICE_ID = "SERVICE_ID"
ENV_OSS_MOUNT_REQUIRED = "OSS_MOUNT_REQUIRED"


def get_redis_url() -> str:
"""Get Redis URL from env with default fallback."""
value = os.getenv(ENV_REDIS_URL, "").strip()
return value or REDIS_URL_DEFAULT


def get_idle_timeout_seconds() -> int:
"""Get idle timeout seconds from env with safe default fallback."""
raw = os.getenv(ENV_IDLE_TIMEOUT, "").strip()
if not raw:
return IDLE_TIMEOUT_SECONDS
try:
parsed = int(raw)
except ValueError:
return IDLE_TIMEOUT_SECONDS
if parsed <= 0:
return IDLE_TIMEOUT_SECONDS
return parsed


def get_task_runtime_idle_timeout_seconds() -> int:
"""Get per-task runtime idle timeout from env with safe default fallback."""
raw = os.getenv(ENV_TASK_RUNTIME_IDLE_TIMEOUT, "").strip()
if not raw:
return TASK_RUNTIME_IDLE_TIMEOUT_SECONDS
try:
parsed = int(raw)
except ValueError:
return TASK_RUNTIME_IDLE_TIMEOUT_SECONDS
if parsed <= 0:
return TASK_RUNTIME_IDLE_TIMEOUT_SECONDS
return parsed


def build_task_queue_key(workstation_id: str) -> str:
"""Build Redis task queue key."""
return TASK_QUEUE_KEY_TEMPLATE.format(workstation_id=workstation_id)


def build_control_queue_key(workstation_id: str) -> str:
"""Build Redis control queue key."""
return CONTROL_QUEUE_KEY_TEMPLATE.format(workstation_id=workstation_id)


def build_log_stream_key(workstation_id: str, task_id: str) -> str:
"""Build Redis log stream key."""
return LOG_STREAM_KEY_TEMPLATE.format(
workstation_id=workstation_id,
task_id=task_id,
)
5 changes: 5 additions & 0 deletions src/linkwork_agent_sdk/engine/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Engine package."""

from .agent_engine import AgentEngine

__all__ = ["AgentEngine"]
Loading