Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2b1fce5
feat(persistence): add shared atomic-JSON write primitive
Tokisakix Jul 5, 2026
f1c4c30
feat(config): split secrets into credentials.json (P1.1)
Tokisakix Jul 5, 2026
c3f05d3
feat(config): add backend config section (P1.2)
Tokisakix Jul 5, 2026
5549e3f
feat(config): central EnvSpec registry (P2.1)
Tokisakix Jul 5, 2026
a5858ac
refactor(llm): route sync credential resolution through EnvSpec regis…
Tokisakix Jul 5, 2026
3ea6b8b
refactor(llm): route async + embedding/vlm through EnvSpec registry (…
Tokisakix Jul 5, 2026
0411e7c
feat(config): env reference markdown emitter (P2.4)
Tokisakix Jul 5, 2026
ea13b86
refactor(persistence): backend+memory registries use atomic writes (P…
Tokisakix Jul 5, 2026
bb6c47b
feat(persistence): root layout manifest (P3.3)
Tokisakix Jul 5, 2026
5adc644
feat(persistence): unified retention/GC across planes (P3.4)
Tokisakix Jul 5, 2026
1649e79
perf(llm): cache constructed chat clients by provider identity (P4.1)
Tokisakix Jul 5, 2026
ca8bc11
feat(flow): AgentParam.to() rebinds provider, type-dispatched (P4.2)
Tokisakix Jul 5, 2026
2bab768
feat(session): optional provider slot + type-dispatched Session.to() …
Tokisakix Jul 5, 2026
6cc532a
feat(session): loop falls back to session-bound provider (P4.4)
Tokisakix Jul 5, 2026
3b043b9
test(session): guard that a bound Provider is never persisted (P4.6)
Tokisakix Jul 5, 2026
47ab8ff
feat(flow): register nested Workflow/Agent children (module tree) (P5.1)
Tokisakix Jul 5, 2026
a1f13f5
feat(backend): opensandbox reads domain/flag via registry + backend c…
Tokisakix Jul 5, 2026
731d618
feat(flow): ResourceManifest + static collector; commit Workflow.to t…
Tokisakix Jul 6, 2026
233f31c
feat(flow): Workflow.compile() -> CompiledWorkflow (P5.3)
Tokisakix Jul 6, 2026
9868062
feat(flow): CompiledWorkflow.validate() pre-flight (P5.4)
Tokisakix Jul 6, 2026
cdc0450
feat(flow): CompiledWorkflow lifecycle context manager (P5.5)
Tokisakix Jul 6, 2026
3e9d9d7
docs(example): add 12_compile.py demonstrating Workflow.compile() (P5.6)
Tokisakix Jul 6, 2026
b2c2d5c
chore(release): bump version to 1.3.0 + README example ladder (P6)
Tokisakix Jul 6, 2026
957da90
docs(example): sync example ladder README with rows 11-12
Tokisakix Jul 7, 2026
7e37c5a
test(example): offline smoke tests for the no-key examples (02, 06)
Tokisakix Jul 7, 2026
29c0132
fix(backend): track current code-interpreter image v1.1.0 (P2.6)
Tokisakix Jul 7, 2026
18462fd
chore(deps): upgrade opensandbox + openviking extras to latest
Tokisakix Jul 7, 2026
96961a0
fix(ci): gate litellm resolver test and align integration workflows
Tokisakix Jul 8, 2026
ea6284a
fix(opensandbox): eliminate CI flakes without pytest reruns
Tokisakix Jul 8, 2026
d799123
style(opensandbox): ruff-format stability helpers
Tokisakix Jul 8, 2026
1856976
fix(opensandbox): bound code.run with default timeout and one retry
Tokisakix Jul 8, 2026
293556e
fix(opensandbox): limit stdout-race rerun to print probes only
Tokisakix Jul 8, 2026
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
18 changes: 9 additions & 9 deletions .github/workflows/ci-test-opensandbox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'tests/backends/**'
- 'tests/conformance/**'
- 'tests/session/**'
- 'scripts/ci_opensandbox_prepare.sh'
- 'pyproject.toml'
- 'uv.lock'
pull_request:
Expand All @@ -20,6 +21,7 @@ on:
- 'tests/backends/**'
- 'tests/conformance/**'
- 'tests/session/**'
- 'scripts/ci_opensandbox_prepare.sh'
- 'pyproject.toml'
- 'uv.lock'

Expand All @@ -30,9 +32,6 @@ jobs:
test-opensandbox:
name: pytest (opensandbox)
runs-on: ubuntu-latest
# OpenSandbox tests require a running server; allow failure in PRs
# until the CI environment is verified stable.
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
Expand All @@ -50,7 +49,10 @@ jobs:
export OPENSANDBOX_INSECURE_SERVER=YES
uv run opensandbox-server init-config --example docker .sandbox.toml
- name: Pre-pull sandbox image
run: docker pull opensandbox/code-interpreter:v1.0.2
run: |
IMAGE="$(uv run python -c 'from rath.backend.opensandbox import OpenSandboxBackend; print(OpenSandboxBackend._DEFAULT_IMAGE)')"
echo "Pre-pulling ${IMAGE}"
docker pull "${IMAGE}"
- name: Start OpenSandbox server
run: |
export OPENSANDBOX_INSECURE_SERVER=YES
Expand All @@ -65,9 +67,7 @@ jobs:
done
echo "OpenSandbox server failed to start" >&2
exit 1
- name: Warm up OpenSandbox
run: bash scripts/ci_opensandbox_prepare.sh
- name: Run OpenSandbox tests
# Backend tests hit a real Docker daemon; the server can race
# stdout capture against exit_code on small jobs. Allow each
# test up to 2 reruns to ride out transient infra flakes
# (pytest-rerunfailures is a dev dep).
run: uv run pytest -m opensandbox --reruns 2 --reruns-delay 2
run: uv run pytest -m opensandbox
7 changes: 6 additions & 1 deletion .github/workflows/ci-test-openviking.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jobs:
- uses: astral-sh/setup-uv@v5
with:
python-version: '3.12'
# When repository secrets are absent we skip uv sync; without this
# the post-job cache prune fails because no cache dir was created.
prune-cache: false
- name: Install dev dependencies
run: uv sync --dev --frozen
- name: Check OpenViking credentials
id: creds
env:
Expand All @@ -57,7 +62,7 @@ jobs:
fi
- name: Install OpenViking SDK
if: steps.creds.outputs.available == 'true'
run: uv sync --extra openviking --frozen
run: uv sync --dev --extra openviking --frozen
- name: Start OpenViking server
if: steps.creds.outputs.available == 'true'
env:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ python example/01_hello_agent.py
| 09 | [`09_memory.py`](example/09_memory.py) | Use the local memory backend to remember, recall, and optionally commit a live turn. | no |
| 10 | [`10_provider_variation.py`](example/10_provider_variation.py) | Swap model vendors by changing `Provider`, while keeping Session and Workflow code stable. | yes |
| 11 | [`11_dynamic_selector.py`](example/11_dynamic_selector.py) | Route between self-describing workflows with `flow.Selector`: `if` branching and a `while` loop that ends on `flow.EmptyWorkflow`. | yes |
| 12 | [`12_compile.py`](example/12_compile.py) | Statically `compile()` a workflow: inspect its resource manifest, run offline `validate()`, and use the lifecycle context manager. | no |

Read [`example/README.md`](example/README.md) for setup details and shared helpers.

Expand Down
1 change: 1 addition & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ python example/01_hello_agent.py
| 09 | [`09_memory.py`](example/09_memory.py) | 使用本地 memory 后端进行 remember、recall,并可选地 commit 一个真实回合。 | 否 |
| 10 | [`10_provider_variation.py`](example/10_provider_variation.py) | 通过更改 `Provider` 切换模型厂商,同时保持 Session 和 Workflow 代码稳定。 | 是 |
| 11 | [`11_dynamic_selector.py`](example/11_dynamic_selector.py) | 使用 `flow.Selector` 在自描述的 workflows 之间路由:`if` 分支和一个在 `flow.EmptyWorkflow` 时结束的 `while` 循环。 | 是 |
| 12 | [`12_compile.py`](example/12_compile.py) | 静态 `compile()` 一个 workflow:查看其资源清单、离线 `validate()`、并使用生命周期上下文管理器。 | 否 |

阅读 [`example/README.md`](example/README.md) 获取设置细节和共享 helpers。

Expand Down
90 changes: 90 additions & 0 deletions example/12_compile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""12 · Workflow compile — static resource manifest & lifecycle (no LLM key).

`Workflow.compile()` is OpenRath's torch-like static pass: before a workflow
runs, it walks the module tree and produces a `ResourceManifest` of every
reachable provider, memory binding, and agent — plus any `Selector` nodes,
recorded as *dynamic* (their runtime routing is decided by the model, never
guessed). Use it to:

* inspect the static graph (`cw.manifest`, `repr(cw)`),
* fail fast before a run (`cw.validate()` — offline, no model call),
* acquire/release planned resources deterministically (`with wf.compile():`).

Compiling runs no model and materializes no session, so this needs **no key**.

Run:
python example/12_compile.py
"""

from __future__ import annotations

from rath import flow
from rath.flow.agent_param import AgentParam
from rath.llm import Provider
from rath.session import Session


class ResearchTeam(flow.Workflow):
"""A tiny nested workflow: a coordinator with two specialist sub-agents."""

def __init__(self) -> None:
super().__init__(description="research team")
# Nested Workflow children register into the module tree (torch-like).
self.triage = _Specialist("Triage quickly.", "gpt-5.5")
self.deep = _Specialist("Answer in depth.", "claude-sonnet-4-6")
# A leaf AgentParam registered directly on this workflow.
self.summarizer = AgentParam(
agent_session=Session.from_agent_prompt("Summarize the team's findings."),
provider=Provider(model="gpt-5.5", api_key="sk-example"),
)

def forward(self, session: Session) -> Session: # pragma: no cover - demo only
return session


class _Specialist(flow.Workflow):
def __init__(self, prompt: str, model: str) -> None:
super().__init__(description=prompt)
self.agent = AgentParam(
agent_session=Session.from_agent_prompt(prompt),
provider=Provider(model=model, api_key="sk-example"),
)

def forward(self, session: Session) -> Session: # pragma: no cover - demo only
return session


def main() -> None:
team = ResearchTeam()

# 1) Compile: a static pass over the module tree. No model runs.
compiled = team.compile()
print("Compiled workflow:")
print(repr(compiled))

# 2) Inspect the static resource manifest.
manifest = compiled.manifest
print("\nReachable provider models:", manifest.provider_models())
print("Agents:")
for agent in manifest.agents:
print(
f" - {agent.path}: model={agent.provider.model} memory={agent.has_memory}"
)
if manifest.dynamic_nodes:
print("Dynamic nodes (runtime-decided):")
for node in manifest.dynamic_nodes:
print(f" - {node.path} [{node.kind}]: {node.reason}")

# 3) Pre-flight validation — offline, before any run.
problems = compiled.validate()
print("\nvalidate() problems:", problems or "none — ready to run")

# 4) Deterministic resource lifecycle. Bound memory stores (none here) are
# acquired on enter and released on exit.
with team.compile() as live:
print(f"\nInside lifecycle: {len(live.manifest.agents)} agents ready.")
print("Resources released on exit.")


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ python example/01_hello_agent.py
| 08 | [08_compress.py](08_compress.py) | `flow.Compressor` to shrink context | yes |
| 09 | [09_memory.py](09_memory.py) | `flow.Agent(memory=...)`: remember / recall / commit | **no**\* |
| 10 | [10_provider_variation.py](10_provider_variation.py) | swap the LLM vendor via `Provider` | yes |
| 11 | [11_dynamic_selector.py](11_dynamic_selector.py) | `flow.Selector` — LLM-routed `if` / `while` over workflows | yes |
| 12 | [12_compile.py](12_compile.py) | `Workflow.compile()` — static resource manifest, offline `validate()`, lifecycle | **no** |

\* 09 runs key-free using the local memory backend; a key only unlocks an
optional live turn at the end.
Expand All @@ -57,6 +59,8 @@ OpenRath borrows PyTorch's shape. The ladder walks the same analogy:
| kernel / op | tool (`FlowToolCall`) | 04, 05, 06 |
| `nn.Parameter` | `flow.AgentParam` / `Provider` | 01, 10 |
| `nn.Module` | `flow.Agent` / `flow.Workflow` | 01, 08 |
| control flow | `flow.Selector` | 11 |
| `torch.compile` | `Workflow.compile()` | 12 |

## Shared helpers (`_shared/`)

Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "openrath"
version = "1.2.2"
version = "1.3.0"
description = "An open-source, torch-like API framework for dynamic multi-agent workflow."
readme = "README.md"
requires-python = ">=3.10,<3.14"
Expand Down Expand Up @@ -34,12 +34,12 @@ litellm = [
"litellm>=1.80,<1.88",
]
opensandbox = [
"opensandbox>=0.1.7",
"opensandbox>=0.1.13",
"opensandbox-code-interpreter>=0.1.2",
"opensandbox-server>=0.1.12",
"opensandbox-server>=0.2.1",
]
openviking = [
"openviking>=0.2.6",
"openviking>=0.4.7",
]

[tool.ruff]
Expand Down
21 changes: 21 additions & 0 deletions scripts/ci_opensandbox_prepare.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# CI-only: create and close one sandbox so pytest never hits cold-start create.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"

export OPENSANDBOX_INSECURE_SERVER="${OPENSANDBOX_INSECURE_SERVER:-YES}"

echo "Warming up OpenSandbox (create + close one sandbox)..."
uv run python -c "
from rath.backend import get

backend = get('opensandbox')
sandbox = backend.open()
try:
print(f'warm-up ok: {sandbox.handle}')
finally:
backend.close(sandbox)
"
25 changes: 11 additions & 14 deletions src/rath/_async/aanthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,37 @@

from __future__ import annotations

import os
from typing import Any

from anthropic import AsyncAnthropic

from rath._async.aretry import aretry_with_backoff
from rath.config.env import env_value
from rath.llm.anthropic.client import (
ANTHROPIC_RETRYABLE,
_config_provider_entry,
_resolve_anthropic_base_url,
_resolve_anthropic_key,
)
from rath.llm.anthropic.create_kwargs import build_anthropic_kwargs
from rath.llm.anthropic.normalize import normalize_anthropic_response
from rath.llm.chat_request import RathLLMChatRequest
from rath.llm.chat_response import RathLLMChatResponse
from rath.llm.credentials import resolve_credential
from rath.llm.provider import Provider

__all__ = ["RathAnthropicAsyncChatClient"]

# The async client shares the sync resolvers (Provider > env > config); these
# aliases give the async module its own named entry points for tests/clarity.
_resolve_async_anthropic_key = _resolve_anthropic_key
_resolve_async_anthropic_base_url = _resolve_anthropic_base_url


class RathAnthropicAsyncChatClient:
"""Async client around ``anthropic.AsyncAnthropic().messages.create``."""

def __init__(self, provider: Provider) -> None:
entry = _config_provider_entry() if not provider.api_key else None
key = resolve_credential(
provider.api_key,
os.environ.get("ANTHROPIC_API_KEY"),
getattr(entry, "api_key", None),
)
key = _resolve_async_anthropic_key(provider)
if not key:
raise ValueError(
"No Anthropic api_key found: Provider.api_key is empty, "
Expand All @@ -48,11 +49,7 @@ def __init__(self, provider: Provider) -> None:
)
self._provider = provider
init_kw: dict[str, Any] = {"api_key": key}
bu = resolve_credential(
provider.base_url,
os.environ.get("ANTHROPIC_BASE_URL"),
getattr(entry, "base_url", None),
)
bu = _resolve_async_anthropic_base_url(provider)
if bu:
init_kw["base_url"] = bu
self._client = AsyncAnthropic(**init_kw)
Expand All @@ -65,7 +62,7 @@ async def acomplete(self, req: RathLLMChatRequest) -> RathLLMChatResponse:
"""Run ``messages.create`` (async) and normalize the response."""
default_model = (
self._provider.model
or os.environ.get("ANTHROPIC_DEFAULT_MODEL")
or env_value("ANTHROPIC_DEFAULT_MODEL")
or getattr(_config_provider_entry(), "model", None)
)
kwargs = build_anthropic_kwargs(req, default_model=default_model)
Expand Down
24 changes: 12 additions & 12 deletions src/rath/_async/aopenai.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

from __future__ import annotations

import os
from typing import Any, AsyncIterator

from openai import (
Expand All @@ -30,6 +29,7 @@
)

from rath._async.aretry import aretry_with_backoff
from rath.config.env import env_value
from rath.llm.chat_request import RathLLMChatRequest
from rath.llm.chat_response import (
RathLLMChatResponse,
Expand All @@ -54,8 +54,8 @@ def _resolve_base_url(provider: Provider) -> str:
entry = _config_provider_entry() if not provider.base_url else None
return resolve_credential(
provider.base_url,
os.environ.get("OPENAI_BASE_URL"),
os.environ.get("AZURE_OPENAI_ENDPOINT"),
env_value("OPENAI_BASE_URL"),
env_value("AZURE_OPENAI_ENDPOINT"),
getattr(entry, "base_url", None),
)

Expand All @@ -66,15 +66,15 @@ def _resolve_api_key(provider: Provider, base_url: str) -> str:
if _is_azure_endpoint(base_url):
return resolve_credential(
provider.api_key,
os.environ.get("AZURE_OPENAI_API_KEY"),
os.environ.get("AZURE_API_KEY"),
os.environ.get("OPENAI_API_KEY"),
env_value("AZURE_OPENAI_API_KEY"),
env_value("AZURE_API_KEY"),
env_value("OPENAI_API_KEY"),
config_key,
)
return resolve_credential(
provider.api_key,
os.environ.get("OPENAI_API_KEY"),
os.environ.get("AZURE_OPENAI_API_KEY"),
env_value("OPENAI_API_KEY"),
env_value("AZURE_OPENAI_API_KEY"),
config_key,
)

Expand All @@ -98,8 +98,8 @@ def __init__(self, provider: Provider) -> None:
use_azure_legacy = _is_azure_endpoint(base_url) and "/openai/v1" not in base_url
if use_azure_legacy:
api_version = (
os.environ.get("OPENAI_API_VERSION")
or os.environ.get("AZURE_OPENAI_API_VERSION")
env_value("OPENAI_API_VERSION")
or env_value("AZURE_OPENAI_API_VERSION")
or "2024-10-21"
)
self._client = AsyncAzureOpenAI(
Expand All @@ -121,7 +121,7 @@ async def acomplete(self, req: RathLLMChatRequest) -> RathLLMChatResponse:
"""Run ``chat.completions.create`` (async) and normalize the response."""
default_model = (
self._provider.model
or os.environ.get("OPENAI_DEFAULT_MODEL")
or env_value("OPENAI_DEFAULT_MODEL")
or _config_default_model()
)
kwargs = to_create_kwargs(req, default_model=default_model)
Expand All @@ -143,7 +143,7 @@ async def acomplete_stream(
"""Yield ``RathLLMStreamDelta`` for each chunk of a streaming completion."""
default_model = (
self._provider.model
or os.environ.get("OPENAI_DEFAULT_MODEL")
or env_value("OPENAI_DEFAULT_MODEL")
or _config_default_model()
)
kwargs = to_create_kwargs_stream(req, default_model=default_model)
Expand Down
Loading
Loading