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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions .claude/skills/porting-to-canyonos/validation/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,8 @@


def _base_requirements():
agent = [
"grpcio",
"grpcio-tools",
"redis",
"pyyaml",
"psutil",
"ipdb",
"ipython",
"boto3",
]
workflow = [*agent, "flask", "sqlalchemy", "psycopg[binary]"]
agent = ["grpcio", "protobuf", "redis"]
workflow = [*agent, "flask"]
try:
from canyonos_core import stub_generator
except Exception: # noqa: BLE001 - a broken install must not crash validation
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center">
<img src="images/canyonos-banner.gif" alt="CanyonOS" width="600">
<img src="images/canyonos-banner.gif" alt="CanyonOS" width="720" height="123">
</p>

## CanyonOS turns plain Python into a running, distributed workflow — without changing a line of code.
Expand Down Expand Up @@ -138,6 +138,15 @@ canyonos test "Hello World!" --json

### 3. Deploy

<details>
<summary>Supported dependency versions</summary>

If your code imports any of these, it must allow at least this version. Older isn't supported, sorry!

`grpcio>=1.76.0` · `protobuf>=6.31.1` · `redis>=3.5`

</details>

Deploy the project fully, configured by the config files.
On deploy success, a `POST` endpoint will be returned, in which you can send your workflow queries to.

Expand Down
62 changes: 32 additions & 30 deletions packages/core/canyonos_core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from canyonos_core.controller.utils.config_env import load_config
from canyonos_core.controller.utils.env_file import resolve_env_file
from canyonos_core.schema import (
DependencyPinConflict,
check_project,
declarations_by_name,
render_violation,
Expand Down Expand Up @@ -182,33 +181,6 @@ def _normalize_requirements(agent_cfg):
return list(agent_cfg.get("requirements") or [])


def _check_dependency_pins(manifest):
"""Fail the build when an app pin cannot share a version with a platform pin.

Every service is checked before the first one is built, so a project with
two bad pins is told about both instead of one per run.
"""
from canyonos_core.stub_generator import _platform_overrides

violations = []
for index, service in enumerate(manifest.agents):
try:
_platform_overrides(
getattr(service, "requirements", ()),
service=index,
manifest_path=manifest.path,
lines=getattr(service, "requirement_lines", ()),
)
except DependencyPinConflict as conflict:
violations.extend(conflict.violations)

if violations:
_reject(
violations,
"Dependency pins rejected: %d conflict(s) found; nothing was built.",
)


def _docker_platform():
"""Return the target Docker platform for portable runtime images."""
from canyonos_core.stub_generator import target_docker_platform
Expand Down Expand Up @@ -370,13 +342,43 @@ def _run_build(config_path):
# so a file that is not YAML at all is rendered as a violation too, and it
# is handed source_root so a service whose code is missing fails here
# rather than being skipped out of a deploy that then reports success.
manifest = validate_or_exit(config_path, declarations_dir, source_root)
_check_dependency_pins(manifest)
validate_or_exit(config_path, declarations_dir, source_root)

config = _load_config(config_path)
agents = config.get("agents", [])
package_dir = _get_package_dir()

from canyonos_core.stub_generator import (
BASE_AGENT_REQUIREMENTS,
BASE_WORKFLOW_REQUIREMENTS,
too_new_requirements,
too_old_requirements,
)

too_old = []
for agent in agents:
requirements = _normalize_requirements(agent)
base = (
BASE_WORKFLOW_REQUIREMENTS
if agent.get("type", "agent") == "workflow"
else BASE_AGENT_REQUIREMENTS
)
too_old += [
f"{agent['name']} currently requires {asked}, but CanyonOS only supports {supported}"
for asked, supported in too_old_requirements(requirements, base)
]
for asked, tested in too_new_requirements(requirements, base):
logger.warning(
"%s currently requires %s, but CanyonOS has only tested %s; it may not work",
agent["name"],
asked,
tested,
)
for message in too_old:
logger.error("%s", message)
if too_old:
sys.exit(1)

# -------------------------------------------------------------- #
# Step 1: Discover agent YAML files and generate Python stubs #
# -------------------------------------------------------------- #
Expand Down
22 changes: 15 additions & 7 deletions packages/core/canyonos_core/controller/local_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import local_controler_pb2
import local_controler_pb2_grpc


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -216,8 +217,7 @@ def _start_llm_proxy(self, redis_host, redis_port):
"""
import socket
import subprocess

import requests
import urllib.request

# An orphaned proxy on 8081 would answer the /healthz probe below and mask
# one of ours that never bound, so prove the port free before spawning.
Expand All @@ -242,9 +242,13 @@ def _start_llm_proxy(self, redis_host, redis_port):
"CANYONOS_REDIS_PORT": str(redis_port),
}
)
# The image gives the proxy its own venv so its packages never share versions with the agent's.
proxy_python = "/opt/canyonos-proxy/bin/python"
if not os.path.exists(proxy_python):
proxy_python = sys.executable
try:
proxy_process = subprocess.Popen(
[sys.executable, "-m", "canyonos_core.llm_proxy"],
[proxy_python, "-m", "canyonos_core.llm_proxy"],
env=proxy_env,
)
except Exception as e:
Expand All @@ -257,6 +261,7 @@ def _start_llm_proxy(self, redis_host, redis_port):
# Popen only raises if the process can't be spawned -- it returns a healthy
# handle even if the proxy starts and dies immediately, so poll /healthz.
deadline = time.time() + 10
last_error = None
while time.time() < deadline:
if proxy_process.poll() is not None:
raise RuntimeError(
Expand All @@ -265,15 +270,18 @@ def _start_llm_proxy(self, redis_host, redis_port):
"otherwise fail silently."
)
try:
if requests.get("http://127.0.0.1:8081/healthz", timeout=0.5).ok:
with urllib.request.urlopen(
"http://127.0.0.1:8081/healthz", timeout=0.5
):
Comment thread
Saaketh0 marked this conversation as resolved.
Fixed
Comment thread
Saaketh0 marked this conversation as resolved.
Fixed
Comment thread
Saaketh0 marked this conversation as resolved.
Dismissed
Comment thread
Saaketh0 marked this conversation as resolved.
Dismissed
break
except requests.exceptions.RequestException:
pass
except OSError as e:
last_error = e
time.sleep(0.2)
else:
proxy_process.kill()
raise RuntimeError(
"LLM proxy did not become healthy on 127.0.0.1:8081 within 10s; "
"LLM proxy did not become healthy on 127.0.0.1:8081 within 10s "
f"(last health check: {last_error}); "
"agent LLM calls are routed through it unconditionally and would "
"otherwise fail silently."
)
Expand Down
5 changes: 4 additions & 1 deletion packages/core/canyonos_core/controller/utils/redis_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ def lock(self, name, timeout):

def expire(self, key, seconds, nx=False):
"""Set a TTL (in seconds) on a key. No-op if the key does not exist."""
return self.client.expire(key, seconds, nx=nx)
# Raw command because redis-py only takes nx= from 4.2.
return self.client.execute_command(
"EXPIRE", key, seconds, *(["NX"] if nx else [])
)

# --- List operations ---

Expand Down
13 changes: 10 additions & 3 deletions packages/core/canyonos_core/llm_proxy/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from __future__ import annotations

from canyonos_core.llm_proxy.providers.anthropic import AnthropicProvider
from canyonos_core.llm_proxy.providers.bedrock import BedrockProvider
from canyonos_core.llm_proxy.providers.openai import OpenAIProvider

# boto3 ships only in images whose agent declares it, so Bedrock is registered only when present.
try:
from canyonos_core.llm_proxy.providers.bedrock import BedrockProvider
except ImportError:
BedrockProvider = None


def build_registry(cfg):
"""Map the URL prefix -> provider instance."""
return {
registry = {
"openai": OpenAIProvider(cfg),
"anthropic": AnthropicProvider(cfg),
"bedrock": BedrockProvider(cfg),
}
if BedrockProvider is not None:
registry["bedrock"] = BedrockProvider(cfg)
return registry
Loading
Loading