worldfoundry 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
worldfoundry/models.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ActionSpec:
9
+ name: str
10
+ description: str
11
+ parameters: dict[str, dict[str, Any]]
12
+ required: tuple[str, ...] = ()
13
+
14
+ def to_dict(self) -> dict[str, Any]:
15
+ data = asdict(self)
16
+ data["required"] = list(self.required)
17
+ return data
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ValidationCheck:
22
+ name: str
23
+ passed: bool
24
+ message: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ValidationResult:
29
+ passed: bool
30
+ score: float
31
+ checks: tuple[ValidationCheck, ...]
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ return {
35
+ "passed": self.passed,
36
+ "score": self.score,
37
+ "checks": [asdict(check) for check in self.checks],
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class Episode:
43
+ id: str
44
+ environment: str
45
+ task: str
46
+ instruction: str
47
+ seed: int
48
+ status: str
49
+ state: dict[str, Any]
50
+ trace: list[dict[str, Any]] = field(default_factory=list)
51
+ created_at: str = ""
52
+ updated_at: str = ""
53
+ validation: ValidationResult | None = None
54
+
55
+ def to_dict(self, *, include_state: bool = True, include_trace: bool = False) -> dict[str, Any]:
56
+ data: dict[str, Any] = {
57
+ "id": self.id,
58
+ "environment": self.environment,
59
+ "task": self.task,
60
+ "instruction": self.instruction,
61
+ "seed": self.seed,
62
+ "status": self.status,
63
+ "created_at": self.created_at,
64
+ "updated_at": self.updated_at,
65
+ "validation": self.validation.to_dict() if self.validation else None,
66
+ }
67
+ if include_state:
68
+ data["state"] = self.state
69
+ if include_trace:
70
+ data["trace"] = self.trace
71
+ return data
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class StepResult:
76
+ episode_id: str
77
+ action: str
78
+ observation: dict[str, Any]
79
+ status: str
80
+ step: int
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ return asdict(self)
84
+
@@ -0,0 +1,185 @@
1
+ """Local agent execution and resource managers for the public SDK."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .engine import EpisodeEngine
12
+ from .environments import get_environment
13
+
14
+
15
+ class WorldFoundryError(Exception):
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Resource:
21
+ id: str
22
+ name: str
23
+ kind: str
24
+ config: dict[str, Any]
25
+
26
+
27
+ class Registry:
28
+ def __init__(self, root: Path):
29
+ self.root = root / "resources"
30
+
31
+ def put(self, resource: Resource) -> Resource:
32
+ folder = self.root / resource.kind
33
+ folder.mkdir(parents=True, exist_ok=True)
34
+ target = folder / f"{resource.id}.json"
35
+ temporary = target.with_suffix(".tmp")
36
+ temporary.write_text(json.dumps(resource.__dict__, indent=2) + "\n", encoding="utf-8")
37
+ temporary.replace(target)
38
+ return resource
39
+
40
+ def get(self, kind: str, resource_id: str) -> Resource:
41
+ if not resource_id.startswith(f"{kind}_") or "/" in resource_id:
42
+ raise WorldFoundryError(f"Invalid {kind} ID")
43
+ target = self.root / kind / f"{resource_id}.json"
44
+ if not target.exists():
45
+ raise WorldFoundryError(f"Unknown {kind}: {resource_id}")
46
+ return Resource(**json.loads(target.read_text(encoding="utf-8")))
47
+
48
+ def list(self, kind: str) -> list[Resource]:
49
+ folder = self.root / kind
50
+ if not folder.exists():
51
+ return []
52
+ return [self.get(kind, path.stem) for path in sorted(folder.glob("*.json"))]
53
+
54
+
55
+ class ResourceManager:
56
+ def __init__(self, registry: Registry, kind: str):
57
+ self.registry = registry
58
+ self.kind = kind
59
+
60
+ def create(self, name: str | None = None, **config: Any) -> Resource:
61
+ name = name or config.get("template") or self.kind
62
+ resource = Resource(f"{self.kind}_{uuid.uuid4().hex[:12]}", name, self.kind, config)
63
+ return self.registry.put(resource)
64
+
65
+ def get(self, resource_id: str) -> Resource:
66
+ return self.registry.get(self.kind, resource_id)
67
+
68
+ def list(self) -> list[Resource]:
69
+ return self.registry.list(self.kind)
70
+
71
+
72
+ class EnvironmentManager(ResourceManager):
73
+ def create(self, *, template: str, difficulty: str = "beginner", name: str | None = None,
74
+ config: dict[str, Any] | None = None) -> Resource:
75
+ aliases = {"customer_support": "support-desk", "support-desk": "support-desk"}
76
+ if template not in aliases:
77
+ raise WorldFoundryError(f"Unknown environment template: {template}")
78
+ if difficulty != "beginner":
79
+ raise WorldFoundryError("Only beginner difficulty is implemented in the support desk template")
80
+ return super().create(name=name or template, template=aliases[template],
81
+ difficulty=difficulty, settings=config or {})
82
+
83
+
84
+ class AgentTools:
85
+ def __init__(self, engine: EpisodeEngine, episode_id: str, max_steps: int):
86
+ self.engine = engine
87
+ self.episode_id = episode_id
88
+ self.submitted = False
89
+ self.max_steps = max_steps
90
+ self.steps = 0
91
+
92
+ def act(self, action: str, **parameters: Any) -> dict[str, Any]:
93
+ if self.submitted:
94
+ raise WorldFoundryError("Agent already submitted this episode")
95
+ if self.steps >= self.max_steps:
96
+ raise WorldFoundryError("Episode action budget exceeded")
97
+ self.steps += 1
98
+ return self.engine.act(self.episode_id, action, **parameters).observation
99
+
100
+ def submit(self) -> None:
101
+ self.submitted = True
102
+
103
+ def __getattr__(self, action: str) -> Callable[..., dict[str, Any]]:
104
+ names = {item.name for item in get_environment("support-desk").actions()}
105
+ if action not in names:
106
+ raise AttributeError(action)
107
+ return lambda **parameters: self.act(action, **parameters)
108
+
109
+
110
+ class AgentManager(ResourceManager):
111
+ def __init__(self, registry: Registry):
112
+ super().__init__(registry, "agent")
113
+ self.handlers: dict[str, Callable[..., Any]] = {}
114
+
115
+ def register(self, *, name: str, handler: Callable[..., Any] | None = None,
116
+ framework: str = "python", endpoint: str | None = None) -> Resource:
117
+ if (handler is None) == (endpoint is None):
118
+ raise WorldFoundryError("Provide exactly one handler or endpoint")
119
+ if endpoint:
120
+ raise WorldFoundryError("Remote agent endpoints are not executable in this local release")
121
+ if framework != "python":
122
+ raise WorldFoundryError("Use framework='python' for local callables; ADK runner is pending")
123
+ resource = self.create(name=name, framework=framework, invocation="python_callable")
124
+ self.handlers[resource.id] = handler # live Python objects intentionally stay in process
125
+ return resource
126
+
127
+
128
+ @dataclass
129
+ class TrainingRun:
130
+ id: str
131
+ status: str
132
+ episode_ids: list[str]
133
+ scores: list[float]
134
+ report_path: Path
135
+ report_url: str | None = None
136
+
137
+ @property
138
+ def success_rate(self) -> float:
139
+ return sum(score == 1.0 for score in self.scores) / len(self.scores) if self.scores else 0.0
140
+
141
+ @property
142
+ def summary(self) -> dict[str, Any]:
143
+ return {"id": self.id, "status": self.status, "episodes": len(self.episode_ids),
144
+ "success_rate": self.success_rate, "mean_score": sum(self.scores) / len(self.scores)}
145
+
146
+ def wait(self) -> TrainingRun:
147
+ return self # local execution currently completes before returning
148
+
149
+
150
+ class TrainingManager:
151
+ def __init__(self, engine: EpisodeEngine, agents: AgentManager, registry: Registry):
152
+ self.engine, self.agents, self.registry = engine, agents, registry
153
+
154
+ def create(self, *, agent: Resource, environment: Resource, episodes: int = 1,
155
+ concurrency: int = 1, max_steps: int = 30, seed: int = 0) -> TrainingRun:
156
+ if not 1 <= episodes <= 10000 or concurrency != 1 or not 1 <= max_steps <= 1000:
157
+ raise WorldFoundryError("episodes, concurrency, and max_steps must be within supported limits")
158
+ if agent.id not in self.agents.handlers:
159
+ raise WorldFoundryError("Local callable is not registered in this process")
160
+ environment = self.registry.get("environment", environment.id)
161
+ template = environment.config["template"]
162
+ handler = self.agents.handlers[agent.id]
163
+ run_id = f"run_{uuid.uuid4().hex[:12]}"
164
+ ids: list[str] = []
165
+ scores: list[float] = []
166
+ failures: list[str | None] = []
167
+ for index in range(episodes):
168
+ episode = self.engine.start(template, seed=seed + index)
169
+ ids.append(episode.id)
170
+ tools = AgentTools(self.engine, episode.id, max_steps)
171
+ error = None
172
+ try:
173
+ handler(episode.instruction, tools)
174
+ except Exception as exc: # noqa: BLE001 - user supplied agent must not abort the batch
175
+ error = f"{type(exc).__name__}: {exc}"
176
+ result = self.engine.submit(episode.id)
177
+ scores.append(result.score)
178
+ failures.append(error)
179
+ run = TrainingRun(run_id, "completed", ids, scores,
180
+ self.registry.root.parent / "runs" / run_id / "report.json")
181
+ run.report_path.parent.mkdir(parents=True, exist_ok=True)
182
+ run.report_path.write_text(json.dumps({**run.summary, "episode_ids": ids,
183
+ "scores": scores, "agent_errors": failures}, indent=2) + "\n")
184
+ return run
185
+
worldfoundry/server.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from .client import WorldFoundry
10
+ from .environments import EnvironmentError
11
+ from .store import EpisodeNotFound
12
+
13
+
14
+ def _json_bytes(data: Any) -> bytes:
15
+ return (json.dumps(data, indent=2) + "\n").encode()
16
+
17
+
18
+ class APIHandler(BaseHTTPRequestHandler):
19
+ server_version = "WorldFoundry/0.1"
20
+ client: WorldFoundry
21
+
22
+ def log_message(self, format: str, *args: Any) -> None:
23
+ print(f"{self.address_string()} - {format % args}")
24
+
25
+ def _send(self, status: int, data: Any, content_type: str = "application/json") -> None:
26
+ body = _json_bytes(data) if content_type == "application/json" else data.encode()
27
+ self.send_response(status)
28
+ self.send_header("Content-Type", f"{content_type}; charset=utf-8")
29
+ self.send_header("Content-Length", str(len(body)))
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+
33
+ def _body(self) -> dict[str, Any]:
34
+ length = int(self.headers.get("Content-Length", "0"))
35
+ if length == 0:
36
+ return {}
37
+ data = json.loads(self.rfile.read(length))
38
+ if not isinstance(data, dict):
39
+ raise TypeError("JSON body must be an object")
40
+ return data
41
+
42
+ def do_GET(self) -> None:
43
+ path = urlparse(self.path).path
44
+ try:
45
+ if path == "/health":
46
+ self._send(200, {"status": "ok"})
47
+ return
48
+ if path == "/v1/environments":
49
+ self._send(200, {"data": self.client.environment_catalog()})
50
+ return
51
+ if path == "/v1/episodes":
52
+ data = [ep.to_dict(include_state=False) for ep in self.client.list_episodes()]
53
+ self._send(200, {"data": data})
54
+ return
55
+ match = re.fullmatch(r"/v1/episodes/([^/]+)", path)
56
+ if match:
57
+ self._send(200, self.client.get(match.group(1)).to_dict())
58
+ return
59
+ match = re.fullmatch(r"/v1/episodes/([^/]+)/trace", path)
60
+ if match:
61
+ episode = self.client.get(match.group(1))
62
+ text = "\n".join(json.dumps(event, sort_keys=True) for event in episode.trace) + "\n"
63
+ self._send(200, text, "application/x-ndjson")
64
+ return
65
+ self._send(404, {"error": "not_found", "message": f"No route for GET {path}"})
66
+ except EpisodeNotFound as exc:
67
+ self._send(404, {"error": "episode_not_found", "message": str(exc.args[0])})
68
+
69
+ def do_POST(self) -> None:
70
+ path = urlparse(self.path).path
71
+ try:
72
+ body = self._body()
73
+ if path == "/v1/episodes":
74
+ episode = self.client.start(
75
+ body.get("environment", "support-desk"),
76
+ body.get("task", "incident-triage"),
77
+ int(body.get("seed", 0)),
78
+ )
79
+ self._send(201, episode.to_dict())
80
+ return
81
+ match = re.fullmatch(r"/v1/episodes/([^/]+)/actions", path)
82
+ if match:
83
+ action = body.pop("action", None)
84
+ if not action:
85
+ raise ValueError("Body requires an 'action' field")
86
+ params = body.pop("parameters", body)
87
+ self._send(200, self.client.act(match.group(1), action, **params).to_dict())
88
+ return
89
+ match = re.fullmatch(r"/v1/episodes/([^/]+)/submit", path)
90
+ if match:
91
+ self._send(200, self.client.submit(match.group(1)).to_dict())
92
+ return
93
+ self._send(404, {"error": "not_found", "message": f"No route for POST {path}"})
94
+ except json.JSONDecodeError:
95
+ self._send(400, {"error": "invalid_json", "message": "Request body is not valid JSON"})
96
+ except (TypeError, ValueError, EnvironmentError) as exc:
97
+ self._send(400, {"error": "invalid_request", "message": str(exc)})
98
+ except EpisodeNotFound as exc:
99
+ self._send(404, {"error": "episode_not_found", "message": str(exc.args[0])})
100
+
101
+
102
+ def serve(host: str = "127.0.0.1", port: int = 8787, home: str | None = None) -> None:
103
+ handler = type("BoundAPIHandler", (APIHandler,), {"client": WorldFoundry(home)})
104
+ httpd = ThreadingHTTPServer((host, port), handler)
105
+ print(f"WorldFoundry API listening on http://{host}:{port}")
106
+ try:
107
+ httpd.serve_forever()
108
+ except KeyboardInterrupt:
109
+ pass
110
+ finally:
111
+ httpd.server_close()
112
+
worldfoundry/store.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .models import Episode, ValidationCheck, ValidationResult
8
+
9
+
10
+ class EpisodeNotFound(KeyError):
11
+ pass
12
+
13
+
14
+ class LocalStore:
15
+ def __init__(self, root: str | Path | None = None):
16
+ configured = root or os.environ.get("WORLDFOUNDRY_HOME")
17
+ self.root = Path(configured).expanduser() if configured else Path.home() / ".worldfoundry"
18
+ self.episodes_dir = self.root / "episodes"
19
+
20
+ def save(self, episode: Episode) -> None:
21
+ self.episodes_dir.mkdir(parents=True, exist_ok=True)
22
+ path = self.episodes_dir / f"{episode.id}.json"
23
+ temp = path.with_suffix(".tmp")
24
+ temp.write_text(json.dumps(episode.to_dict(include_trace=True), indent=2) + "\n", encoding="utf-8")
25
+ temp.replace(path)
26
+
27
+ def load(self, episode_id: str) -> Episode:
28
+ path = self.episodes_dir / f"{episode_id}.json"
29
+ if not path.exists():
30
+ raise EpisodeNotFound(episode_id)
31
+ data = json.loads(path.read_text(encoding="utf-8"))
32
+ validation = None
33
+ if data.get("validation"):
34
+ raw = data["validation"]
35
+ validation = ValidationResult(
36
+ raw["passed"],
37
+ raw["score"],
38
+ tuple(ValidationCheck(**check) for check in raw["checks"]),
39
+ )
40
+ return Episode(
41
+ id=data["id"],
42
+ environment=data["environment"],
43
+ task=data["task"],
44
+ instruction=data["instruction"],
45
+ seed=data["seed"],
46
+ status=data["status"],
47
+ state=data["state"],
48
+ trace=data.get("trace", []),
49
+ created_at=data["created_at"],
50
+ updated_at=data["updated_at"],
51
+ validation=validation,
52
+ )
53
+
54
+ def list(self) -> list[Episode]:
55
+ if not self.episodes_dir.exists():
56
+ return []
57
+ return [self.load(path.stem) for path in sorted(self.episodes_dir.glob("*.json"))]
58
+
59
+ def export_trace(self, episode_id: str, path: str | Path) -> Path:
60
+ episode = self.load(episode_id)
61
+ target = Path(path)
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ lines = (json.dumps(event, sort_keys=True) for event in episode.trace)
64
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
65
+ return target
66
+
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.5
2
+ Name: worldfoundry
3
+ Version: 0.2.0
4
+ Summary: Agent development and training-experience SDK
5
+ Author: WorldFoundry
6
+ License: Apache-2.0
7
+ License-File: LICENSE
8
+ Keywords: agents,evaluation,simulation,training-environments
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.11
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.8; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # WorldFoundry
21
+
22
+ **Training infrastructure for AI agents.**
23
+
24
+ WorldFoundry gives developers programmable environments where agents can practice real-world
25
+ tasks, interact with simulated systems, learn from failures, and improve through repeated
26
+ experiences.
27
+
28
+ Build agents anywhere. Train and test them in WorldFoundry.
29
+
30
+ > **Current status:** this repository builds the `worldfoundry` distribution locally. Check the
31
+ > [PyPI project page](https://pypi.org/project/worldfoundry/) for a published release before
32
+ > using registry install commands. The Homebrew formula is planned and is not published.
33
+
34
+ ## What works now
35
+
36
+ - A Python SDK and `worldfoundry` / `wf` command-line interface.
37
+ - Persistent, stateful episodes in a simulated support desk.
38
+ - Declared action schemas and validation before actions mutate state.
39
+ - Deterministic task grading with per-check results and a numeric score.
40
+ - Complete traces for starts, successful actions, failed actions, and submissions.
41
+ - JSONL trace export.
42
+ - A dependency-free REST API usable with `curl`.
43
+ - Framework-neutral callables ready to wrap as Google ADK function tools.
44
+ - Local SDK resource registration and sequential callable-agent batch runs with JSON reports.
45
+
46
+ This is trajectory and evaluation infrastructure. It does **not** perform reinforcement learning,
47
+ fine-tuning, reward-model training, or model-weight updates.
48
+
49
+ The expanded SDK contract and its current limits are in [`docs/sdk-contract.md`](docs/sdk-contract.md).
50
+ The Supabase control-plane schema, private trace storage, and authenticated Edge API are deployed;
51
+ managed Python workers and customer billing are not yet deployed.
52
+
53
+ The cloud usage ledger records client-reported episodes for operational visibility; it is not a
54
+ billing system. Stripe metering, verified managed-compute usage, and payment webhooks are not
55
+ connected to the SDK. Source is published at [AMallur/WorldFoundry](https://github.com/AMallur/WorldFoundry).
56
+ The GitHub release workflow verifies the package before publishing through PyPI Trusted
57
+ Publishing.
58
+
59
+ ## Install — verified local
60
+
61
+ From this repository:
62
+
63
+ ```bash
64
+ python3.13 -m venv .venv
65
+ source .venv/bin/activate
66
+ python -m pip install -e .
67
+ worldfoundry --version
68
+ ```
69
+
70
+ With `uv`, without keeping a virtual environment:
71
+
72
+ ```bash
73
+ uvx --python 3.13 --from . worldfoundry --version
74
+ ```
75
+
76
+ The explicit Python version makes the verification environment reproducible; any supported
77
+ Python 3.11–3.13 interpreter can be used.
78
+
79
+ ### Registry installation
80
+
81
+ Use these commands only when a release is visible on the
82
+ [PyPI project page](https://pypi.org/project/worldfoundry/):
83
+
84
+ ```bash
85
+ pip install worldfoundry
86
+ uvx worldfoundry
87
+ ```
88
+
89
+ Homebrew distribution is planned. This command does not work until a formula is published:
90
+
91
+ ```bash
92
+ # FUTURE: no Homebrew formula has been published
93
+ brew install worldfoundry/tap/worldfoundry
94
+ ```
95
+
96
+ ## One-minute CLI experience
97
+
98
+ Keep state in a project-local folder while experimenting:
99
+
100
+ ```bash
101
+ export WORLDFOUNDRY_HOME="$PWD/.wf-data"
102
+
103
+ worldfoundry environments list
104
+ worldfoundry episodes start support-desk --task incident-triage --seed 7
105
+ ```
106
+
107
+ Copy the returned episode ID, then act inside the same persistent simulation:
108
+
109
+ ```bash
110
+ EPISODE_ID=ep_replace_me
111
+
112
+ worldfoundry episodes act "$EPISODE_ID" get_ticket \
113
+ --params '{"ticket_id":"TCK-100"}'
114
+ worldfoundry episodes act "$EPISODE_ID" set_priority \
115
+ --params '{"ticket_id":"TCK-100","priority":"critical"}'
116
+ worldfoundry episodes act "$EPISODE_ID" assign \
117
+ --params '{"ticket_id":"TCK-100","assignee":"on-call"}'
118
+ worldfoundry episodes act "$EPISODE_ID" reply \
119
+ --params '{"ticket_id":"TCK-100","message":"We acknowledge the outage and have opened an incident."}'
120
+ worldfoundry episodes act "$EPISODE_ID" resolve \
121
+ --params '{"ticket_id":"TCK-100"}'
122
+ worldfoundry episodes submit "$EPISODE_ID"
123
+ worldfoundry traces export "$EPISODE_ID" --output trace.jsonl
124
+ ```
125
+
126
+ Submission is a terminal gate. A submitted episode becomes `passed` or `failed` and rejects later
127
+ actions. Start a fresh episode for another attempt.
128
+
129
+ ## Python SDK
130
+
131
+ ```python
132
+ from worldfoundry import WorldFoundry
133
+
134
+ wf = WorldFoundry(".wf-data")
135
+ episode = wf.start("support-desk", task="incident-triage", seed=7)
136
+
137
+ wf.act(episode.id, "set_priority", ticket_id="TCK-100", priority="critical")
138
+ wf.act(episode.id, "assign", ticket_id="TCK-100", assignee="on-call")
139
+ wf.act(
140
+ episode.id,
141
+ "reply",
142
+ ticket_id="TCK-100",
143
+ message="We acknowledge the outage and have opened an incident.",
144
+ )
145
+ wf.act(episode.id, "resolve", ticket_id="TCK-100")
146
+
147
+ result = wf.submit(episode.id)
148
+ print(result.passed, result.score)
149
+ wf.export_trace(episode.id, "trace.jsonl")
150
+ ```
151
+
152
+ A complete runnable version is in [`examples/python_episode.py`](examples/python_episode.py).
153
+
154
+ ## REST API and `curl`
155
+
156
+ Start the local server:
157
+
158
+ ```bash
159
+ WORLDFOUNDRY_HOME="$PWD/.wf-data" worldfoundry serve --port 8787
160
+ ```
161
+
162
+ In another terminal:
163
+
164
+ ```bash
165
+ curl -s http://127.0.0.1:8787/health
166
+
167
+ curl -s -X POST http://127.0.0.1:8787/v1/episodes \
168
+ -H 'content-type: application/json' \
169
+ -d '{"environment":"support-desk","task":"incident-triage","seed":7}'
170
+
171
+ EPISODE_ID=ep_replace_me
172
+
173
+ curl -s -X POST "http://127.0.0.1:8787/v1/episodes/$EPISODE_ID/actions" \
174
+ -H 'content-type: application/json' \
175
+ -d '{"action":"set_priority","parameters":{"ticket_id":"TCK-100","priority":"critical"}}'
176
+
177
+ curl -s -X POST "http://127.0.0.1:8787/v1/episodes/$EPISODE_ID/submit"
178
+ curl -s "http://127.0.0.1:8787/v1/episodes/$EPISODE_ID/trace" > trace.jsonl
179
+ ```
180
+
181
+ See [`examples/curl_episode.sh`](examples/curl_episode.sh) for an end-to-end REST run that extracts
182
+ the episode ID without requiring `jq`.
183
+
184
+ ## Environment contract
185
+
186
+ Each environment supplies:
187
+
188
+ 1. An initial state generated from a seed.
189
+ 2. A task instruction.
190
+ 3. Named actions with parameter schemas.
191
+ 4. Stateful action handlers that return observations.
192
+ 5. Deterministic validators that produce individual checks, a score, and pass/fail.
193
+
194
+ All interfaces use the same engine and local store. State lives in
195
+ `$WORLDFOUNDRY_HOME/episodes` or `~/.worldfoundry/episodes` by default. Set the environment variable
196
+ or pass `WorldFoundry(path)` / `--home PATH` for isolated runs.
197
+
198
+ ## Google ADK integration path
199
+
200
+ `worldfoundry.integrations.adk.tools(client, episode_id)` returns two documented Python callables:
201
+ one for environment actions and one for submission. Google ADK can wrap them as function tools
202
+ without making ADK a hard dependency of the core package. See [`docs/adk.md`](docs/adk.md).
203
+
204
+ ## Path to hosted SaaS
205
+
206
+ The local contract deliberately maps to a hosted service:
207
+
208
+ | Local MVP | Hosted evolution |
209
+ |---|---|
210
+ | File-backed episode store | Transactional database plus object storage |
211
+ | In-process environment | Isolated worker or sandbox per episode |
212
+ | Local identity | API keys, workspaces, roles, and scoped tokens |
213
+ | Synchronous action | Idempotent action endpoint with call IDs |
214
+ | JSONL file | Queryable traces plus immutable export |
215
+ | One built-in environment | Versioned environment registry and private environments |
216
+ | Deterministic validator | Versioned eval suites, cohorts, and regression dashboards |
217
+
218
+ Before a public service, add idempotency keys, optimistic concurrency, tenant isolation, quotas,
219
+ signed artifacts, structured logs, worker timeouts, and compatibility tests for environment
220
+ versions. See [`docs/roadmap.md`](docs/roadmap.md) for release gates.
221
+
222
+ ## Develop and test
223
+
224
+ ```bash
225
+ python3.13 -m venv .venv
226
+ source .venv/bin/activate
227
+ python -m pip install -e '.[dev]'
228
+ python -m pytest
229
+ python -m compileall -q src tests examples
230
+ ```
231
+
232
+ The test suite covers a passing SDK episode, failed-action trace recording, CLI persistence and
233
+ export, REST behavior, invalid parameters, and terminal submission semantics.
234
+
235
+ ## License
236
+
237
+ Apache-2.0. See [`LICENSE`](LICENSE).
238
+