diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index d1abe445e9..737721d834 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -102,6 +102,10 @@ policy or network-mode mismatch; only the provider may replace it after the orphan grace, local teardown reservation, and cross-instance teardown lease. Destroy the sandbox, sidecar, and both networks together. +### Tenki `sticky` + +Env values stay strings; parse booleans before SDK calls (`bool("false")` is true). + ### E2B Mount Uploads E2B uploads host mounts during sandbox creation using binary file objects. diff --git a/backend/packages/harness/deerflow/community/tenki/README.md b/backend/packages/harness/deerflow/community/tenki/README.md index e1fa35f872..52769099bc 100644 --- a/backend/packages/harness/deerflow/community/tenki/README.md +++ b/backend/packages/harness/deerflow/community/tenki/README.md @@ -25,6 +25,12 @@ sandbox: PYTHONUNBUFFERED: "1" ``` +`sticky` also accepts an environment reference such as `sticky: $TENKI_STICKY`. +Boolean strings are case-insensitive: `false`, `0`, `off`, and `no` disable host +pinning; `true`, `1`, `on`, and `yes` enable it. Invalid boolean values are +rejected when the provider loads its configuration. Omitted or `null` values +keep pinning disabled. + Install the optional SDK before selecting this provider: ```bash diff --git a/backend/packages/harness/deerflow/community/tenki/provider.py b/backend/packages/harness/deerflow/community/tenki/provider.py index 43b3fe25f7..8b6b8f7d6e 100644 --- a/backend/packages/harness/deerflow/community/tenki/provider.py +++ b/backend/packages/harness/deerflow/community/tenki/provider.py @@ -26,6 +26,8 @@ import uuid from functools import partial from typing import TYPE_CHECKING, Any +from pydantic import TypeAdapter + from deerflow.config import get_app_config from deerflow.sandbox.acquire_serialization import AcquireSerializer from deerflow.sandbox.identity import derive_sandbox_scope_token @@ -141,6 +143,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider replicas = _opt("replicas") idle_timeout = _opt("idle_timeout") max_duration = _opt("max_duration") + sticky = _opt("sticky", False) environment = dict(_opt("environment") or {}) # Fail fast on a misconfigured key (e.g. "bad-key"): the per-call env goes # through the same POSIX-name check in execute_command, but this static @@ -159,7 +162,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider # Off by default (the SDK default). Warm-pool sandboxes stay running # between turns, so host pinning only matters to deployments that # also pause/resume; expose it rather than decide for them. - "sticky": bool(_opt("sticky", False)), + "sticky": TypeAdapter(bool).validate_python(sticky) if sticky is not None else False, "api_key": api_key, # None → SDK falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN "base_url": _opt("base_url"), "image": _opt("image"), # None → Tenki account default base image diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index 0ee85e0ddc..5f3b552207 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -25,6 +25,7 @@ import pytest from deerflow.community.tenki.provider import _BOOTSTRAP_TIMEOUT, TenkiSandboxProvider, _import_client from deerflow.community.tenki.sandbox import TenkiSandbox +from deerflow.config.app_config import AppConfig # ── Fake Tenki SDK ──────────────────────────────────────────────────── @@ -755,6 +756,42 @@ def test_create_waits_client_side_and_configures_lifetime(monkeypatch): provider.shutdown() +@pytest.mark.parametrize( + ("value", "expected"), + [("false", False), ("0", False), ("off", False), ("no", False), ("FALSE", False), ("true", True), ("1", True), ("on", True), ("yes", True)], +) +def test_create_sticky_from_environment(monkeypatch, tmp_path, value, expected): + monkeypatch.setenv("TEST_TENKI_STICKY", value) + config_path = tmp_path / "config.yaml" + config_path.write_text("models: []\nsandbox:\n use: deerflow.community.tenki:TenkiSandboxProvider\n sticky: $TEST_TENKI_STICKY\n idle_timeout: 0\n", encoding="utf-8") + config = AppConfig.from_file(config_path) + client = _FakeClient() + monkeypatch.setattr("deerflow.community.tenki.provider.get_app_config", lambda: config) + monkeypatch.setattr("deerflow.community.tenki.provider._import_client", lambda: lambda **kw: client) + provider = TenkiSandboxProvider() + try: + provider.acquire("thread-1", user_id="u1") + assert client.create_kwargs[0]["sticky"] is expected + finally: + provider.shutdown() + + +@pytest.mark.parametrize("value", [False, True, None]) +def test_create_sticky_native_value(monkeypatch, value): + client = _FakeClient() + provider = _install(monkeypatch, client=client, config_attrs={"sticky": value, "idle_timeout": 0}) + try: + provider.acquire("thread-1", user_id="u1") + assert client.create_kwargs[0]["sticky"] is bool(value) + finally: + provider.shutdown() + + +def test_sticky_rejects_invalid_boolean(monkeypatch): + with pytest.raises(ValueError, match="valid boolean"): + _install(monkeypatch, config_attrs={"sticky": "not-a-boolean"}) + + def test_create_default_lifetime_is_explicit(monkeypatch): client = _FakeClient() provider = _install(monkeypatch, client=client)