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/__init__.py +8 -0
- worldfoundry/__main__.py +3 -0
- worldfoundry/cli.py +94 -0
- worldfoundry/client.py +45 -0
- worldfoundry/cloud.py +143 -0
- worldfoundry/engine.py +142 -0
- worldfoundry/environments.py +165 -0
- worldfoundry/integrations/__init__.py +2 -0
- worldfoundry/integrations/adk.py +25 -0
- worldfoundry/models.py +84 -0
- worldfoundry/platform.py +185 -0
- worldfoundry/server.py +112 -0
- worldfoundry/store.py +66 -0
- worldfoundry-0.2.0.dist-info/METADATA +238 -0
- worldfoundry-0.2.0.dist-info/RECORD +18 -0
- worldfoundry-0.2.0.dist-info/WHEEL +4 -0
- worldfoundry-0.2.0.dist-info/entry_points.txt +3 -0
- worldfoundry-0.2.0.dist-info/licenses/LICENSE +17 -0
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
|
+
|
worldfoundry/platform.py
ADDED
|
@@ -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
|
+
|