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/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""WorldFoundry local training-experience SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import WorldFoundry
|
|
4
|
+
from .models import ActionSpec, Episode, StepResult, ValidationResult
|
|
5
|
+
|
|
6
|
+
__all__ = ["ActionSpec", "Episode", "StepResult", "ValidationResult", "WorldFoundry"]
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
worldfoundry/__main__.py
ADDED
worldfoundry/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .client import WorldFoundry
|
|
11
|
+
from .environments import EnvironmentError
|
|
12
|
+
from .server import serve
|
|
13
|
+
from .store import EpisodeNotFound
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _print(data: Any) -> None:
|
|
17
|
+
print(json.dumps(data, indent=2))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(prog="worldfoundry", description="Local training environments for AI agents")
|
|
22
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
23
|
+
parser.add_argument("--home", help="State directory (default: $WORLDFOUNDRY_HOME or ~/.worldfoundry)")
|
|
24
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
environments = commands.add_parser("environments", help="Discover environments")
|
|
27
|
+
environments.add_subparsers(dest="subcommand", required=True).add_parser("list")
|
|
28
|
+
|
|
29
|
+
episodes = commands.add_parser("episodes", help="Run episodes")
|
|
30
|
+
episode_commands = episodes.add_subparsers(dest="subcommand", required=True)
|
|
31
|
+
start = episode_commands.add_parser("start")
|
|
32
|
+
start.add_argument("environment")
|
|
33
|
+
start.add_argument("--task", default="incident-triage")
|
|
34
|
+
start.add_argument("--seed", type=int, default=0)
|
|
35
|
+
episode_commands.add_parser("list")
|
|
36
|
+
inspect = episode_commands.add_parser("inspect")
|
|
37
|
+
inspect.add_argument("episode_id")
|
|
38
|
+
act = episode_commands.add_parser("act")
|
|
39
|
+
act.add_argument("episode_id")
|
|
40
|
+
act.add_argument("action")
|
|
41
|
+
act.add_argument("--params", default="{}", help="JSON object of action parameters")
|
|
42
|
+
submit = episode_commands.add_parser("submit")
|
|
43
|
+
submit.add_argument("episode_id")
|
|
44
|
+
|
|
45
|
+
traces = commands.add_parser("traces", help="Export episode traces")
|
|
46
|
+
trace_commands = traces.add_subparsers(dest="subcommand", required=True)
|
|
47
|
+
export = trace_commands.add_parser("export")
|
|
48
|
+
export.add_argument("episode_id")
|
|
49
|
+
export.add_argument("--output", "-o", required=True)
|
|
50
|
+
|
|
51
|
+
server = commands.add_parser("serve", help="Run the local REST API")
|
|
52
|
+
server.add_argument("--host", default="127.0.0.1")
|
|
53
|
+
server.add_argument("--port", type=int, default=8787)
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str] | None = None) -> int:
|
|
58
|
+
args = build_parser().parse_args(argv)
|
|
59
|
+
if args.command == "serve":
|
|
60
|
+
serve(args.host, args.port, args.home)
|
|
61
|
+
return 0
|
|
62
|
+
client = WorldFoundry(args.home)
|
|
63
|
+
try:
|
|
64
|
+
if args.command == "environments":
|
|
65
|
+
_print({"data": client.environment_catalog()})
|
|
66
|
+
elif args.command == "episodes" and args.subcommand == "start":
|
|
67
|
+
_print(client.start(args.environment, args.task, args.seed).to_dict())
|
|
68
|
+
elif args.command == "episodes" and args.subcommand == "list":
|
|
69
|
+
_print({"data": [ep.to_dict(include_state=False) for ep in client.list_episodes()]})
|
|
70
|
+
elif args.command == "episodes" and args.subcommand == "inspect":
|
|
71
|
+
_print(client.get(args.episode_id).to_dict())
|
|
72
|
+
elif args.command == "episodes" and args.subcommand == "act":
|
|
73
|
+
params = json.loads(args.params)
|
|
74
|
+
if not isinstance(params, dict):
|
|
75
|
+
raise ValueError("--params must be a JSON object")
|
|
76
|
+
_print(client.act(args.episode_id, args.action, **params).to_dict())
|
|
77
|
+
elif args.command == "episodes" and args.subcommand == "submit":
|
|
78
|
+
_print(client.submit(args.episode_id).to_dict())
|
|
79
|
+
elif args.command == "traces":
|
|
80
|
+
path = client.export_trace(args.episode_id, Path(args.output))
|
|
81
|
+
_print({"episode_id": args.episode_id, "output": str(path), "format": "jsonl"})
|
|
82
|
+
return 0
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
print(f"error: invalid JSON in --params: {exc}", file=sys.stderr)
|
|
85
|
+
except (ValueError, EnvironmentError) as exc:
|
|
86
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
87
|
+
except EpisodeNotFound as exc:
|
|
88
|
+
print(f"error: episode not found: {exc.args[0]}", file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
raise SystemExit(main())
|
|
94
|
+
|
worldfoundry/client.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""WorldFoundry public SDK client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .engine import EpisodeEngine
|
|
8
|
+
from .platform import AgentManager, EnvironmentManager, Registry, ResourceManager, TrainingManager
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .cloud import CloudWorldFoundry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorldFoundry(EpisodeEngine):
|
|
15
|
+
def __new__(cls, home: str | Path | None = None, *, api_key: str | None = None,
|
|
16
|
+
base_url: str | None = None):
|
|
17
|
+
if api_key or base_url:
|
|
18
|
+
if not api_key or not base_url or home is not None:
|
|
19
|
+
raise ValueError("Cloud mode needs both api_key and base_url, without a local home")
|
|
20
|
+
from .cloud import CloudWorldFoundry
|
|
21
|
+
return CloudWorldFoundry(base_url=base_url, api_key=api_key)
|
|
22
|
+
return super().__new__(cls)
|
|
23
|
+
|
|
24
|
+
def __init__(self, home: str | Path | None = None, *, api_key: str | None = None,
|
|
25
|
+
base_url: str | None = None):
|
|
26
|
+
super().__init__(home)
|
|
27
|
+
registry = Registry(self.store.root)
|
|
28
|
+
self.projects = ResourceManager(registry, "project")
|
|
29
|
+
self.environments = EnvironmentManager(registry, "environment")
|
|
30
|
+
self.agents = AgentManager(registry)
|
|
31
|
+
self.scenarios = ResourceManager(registry, "scenario")
|
|
32
|
+
self.datasets = ResourceManager(registry, "dataset")
|
|
33
|
+
self.policies = ResourceManager(registry, "policy")
|
|
34
|
+
self.feedback = ResourceManager(registry, "feedback")
|
|
35
|
+
self.training = TrainingManager(self, self.agents, registry)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def local(cls, data_dir: str | Path | None = None) -> WorldFoundry:
|
|
39
|
+
return cls(data_dir)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def cloud(cls, *, base_url: str, api_key: str) -> CloudWorldFoundry:
|
|
43
|
+
from .cloud import CloudWorldFoundry
|
|
44
|
+
return CloudWorldFoundry(base_url=base_url, api_key=api_key)
|
|
45
|
+
|
worldfoundry/cloud.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Explicit HTTP SDK transport for a separately deployed WorldFoundry API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .platform import WorldFoundryError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPTransport:
|
|
15
|
+
def __init__(self, base_url: str, api_key: str):
|
|
16
|
+
if not base_url.startswith("https://") and not base_url.startswith("http://127.0.0.1:"):
|
|
17
|
+
raise ValueError("Cloud API must use HTTPS (or loopback for development)")
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise ValueError("api_key is required")
|
|
20
|
+
self.base_url = base_url.rstrip("/")
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
|
|
23
|
+
def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
24
|
+
payload = json.dumps(body).encode() if body is not None else None
|
|
25
|
+
req = Request(self.base_url + path, payload, {
|
|
26
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
}, method=method)
|
|
29
|
+
try:
|
|
30
|
+
with urlopen(req, timeout=30) as response:
|
|
31
|
+
return json.load(response)
|
|
32
|
+
except HTTPError as exc:
|
|
33
|
+
raise WorldFoundryError(f"API returned HTTP {exc.code}") from exc
|
|
34
|
+
except URLError as exc:
|
|
35
|
+
raise WorldFoundryError(f"API unavailable: {exc.reason}") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CloudResourceManager:
|
|
39
|
+
def __init__(self, transport: HTTPTransport, path: str):
|
|
40
|
+
self.transport, self.path = transport, path
|
|
41
|
+
|
|
42
|
+
def create(self, **parameters: Any) -> dict[str, Any]:
|
|
43
|
+
return self.transport.request("POST", f"/v1/{self.path}", parameters)
|
|
44
|
+
|
|
45
|
+
def register(self, **parameters: Any) -> dict[str, Any]:
|
|
46
|
+
return self.create(**parameters)
|
|
47
|
+
|
|
48
|
+
def get(self, resource_id: str) -> dict[str, Any]:
|
|
49
|
+
return self.transport.request("GET", f"/v1/{self.path}/{resource_id}")
|
|
50
|
+
|
|
51
|
+
def list(self) -> dict[str, Any]:
|
|
52
|
+
return self.transport.request("GET", f"/v1/{self.path}")
|
|
53
|
+
|
|
54
|
+
def update(self, resource_id: str, **parameters: Any) -> dict[str, Any]:
|
|
55
|
+
return self.transport.request("PATCH", f"/v1/{self.path}/{resource_id}", parameters)
|
|
56
|
+
|
|
57
|
+
def run(self, **parameters: Any) -> dict[str, Any]:
|
|
58
|
+
return self.create(**parameters)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CloudWorldFoundry:
|
|
62
|
+
"""Client contract; requires an independently deployed, authenticated API."""
|
|
63
|
+
def __init__(self, *, base_url: str, api_key: str):
|
|
64
|
+
transport = HTTPTransport(base_url, api_key)
|
|
65
|
+
self.projects = CloudResourceManager(transport, "projects")
|
|
66
|
+
self.environments = CloudResourceManager(transport, "environments")
|
|
67
|
+
self.agents = CloudResourceManager(transport, "agents")
|
|
68
|
+
self.scenarios = CloudResourceManager(transport, "scenarios")
|
|
69
|
+
self.datasets = CloudResourceManager(transport, "datasets")
|
|
70
|
+
self.policies = CloudResourceManager(transport, "policies")
|
|
71
|
+
self.validators = CloudResourceManager(transport, "validators")
|
|
72
|
+
self.runs = CloudResourceManager(transport, "training-runs")
|
|
73
|
+
self.training = CloudTrainingManager(transport)
|
|
74
|
+
self.episodes = CloudResourceManager(transport, "episodes")
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def sign_in(cls, *, project_url: str, publishable_key: str,
|
|
78
|
+
email: str, password: str) -> CloudWorldFoundry:
|
|
79
|
+
"""Exchange Supabase email/password for a user session; keep it in memory only."""
|
|
80
|
+
if not project_url.startswith("https://"):
|
|
81
|
+
raise ValueError("project_url must use HTTPS")
|
|
82
|
+
endpoint = project_url.rstrip("/") + "/auth/v1/token?grant_type=password"
|
|
83
|
+
req = Request(endpoint, json.dumps({"email": email, "password": password}).encode(), {
|
|
84
|
+
"apikey": publishable_key, "Content-Type": "application/json",
|
|
85
|
+
}, method="POST")
|
|
86
|
+
try:
|
|
87
|
+
with urlopen(req, timeout=30) as response:
|
|
88
|
+
token = json.load(response)["access_token"]
|
|
89
|
+
except (HTTPError, URLError) as exc:
|
|
90
|
+
raise WorldFoundryError("Supabase sign-in failed") from exc
|
|
91
|
+
return cls(base_url=project_url.rstrip("/") + "/functions/v1/worldfoundry-api",
|
|
92
|
+
api_key=token)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CloudTrainingManager(CloudResourceManager):
|
|
96
|
+
def __init__(self, transport: HTTPTransport):
|
|
97
|
+
super().__init__(transport, "training-runs")
|
|
98
|
+
|
|
99
|
+
def run_local(self, *, project_id: str, agent: dict[str, Any],
|
|
100
|
+
environment: dict[str, Any], handler: Any, episodes: int = 1,
|
|
101
|
+
seed: int = 0, data_dir: str | Path | None = None) -> dict[str, Any]:
|
|
102
|
+
"""Run user code on this computer, then sync scores to Supabase.
|
|
103
|
+
|
|
104
|
+
The cloud record is not a managed execution claim. A process exit before sync
|
|
105
|
+
leaves a running record that can be inspected and recovered by the caller.
|
|
106
|
+
"""
|
|
107
|
+
from .client import WorldFoundry
|
|
108
|
+
|
|
109
|
+
if not 1 <= episodes <= 10000:
|
|
110
|
+
raise ValueError("episodes must be between 1 and 10000")
|
|
111
|
+
run = self.create(project_id=project_id, agent_id=agent["id"],
|
|
112
|
+
environment_id=environment["id"], episodes=episodes,
|
|
113
|
+
execution="client", seed=seed)
|
|
114
|
+
self.update(run["id"], status="running")
|
|
115
|
+
with tempfile.TemporaryDirectory(prefix="worldfoundry-") if data_dir is None else _existing_dir(data_dir) as root:
|
|
116
|
+
local = WorldFoundry.local(root)
|
|
117
|
+
template = environment.get("config", {}).get("template", "support-desk")
|
|
118
|
+
env = local.environments.create(template=template)
|
|
119
|
+
registered = local.agents.register(name=agent["name"], handler=handler)
|
|
120
|
+
result = local.training.create(agent=registered, environment=env, episodes=episodes, seed=seed)
|
|
121
|
+
for ordinal, episode_id in enumerate(result.episode_ids):
|
|
122
|
+
episode = local.get(episode_id)
|
|
123
|
+
remote_episode = self.transport.request("POST", "/v1/episodes", {
|
|
124
|
+
"run_id": run["id"], "ordinal": ordinal, "seed": seed + ordinal,
|
|
125
|
+
"status": episode.status, "score": result.scores[ordinal],
|
|
126
|
+
})
|
|
127
|
+
self.transport.request("POST", f"/v1/episodes/{remote_episode['id']}/trace",
|
|
128
|
+
{"events": episode.trace})
|
|
129
|
+
completed = self.update(run["id"], status="completed")
|
|
130
|
+
return {"run": completed, "summary": result.summary, "report_url": None}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _existing_dir:
|
|
134
|
+
def __init__(self, path: str | Path):
|
|
135
|
+
self.path = Path(path)
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> Path:
|
|
138
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
return self.path
|
|
140
|
+
|
|
141
|
+
def __exit__(self, *args: object) -> None:
|
|
142
|
+
pass
|
|
143
|
+
|
worldfoundry/engine.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .environments import ENVIRONMENTS, EnvironmentError, get_environment
|
|
9
|
+
from .models import Episode, StepResult, ValidationResult
|
|
10
|
+
from .store import LocalStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _now() -> str:
|
|
14
|
+
return datetime.now(UTC).isoformat()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EpisodeEngine:
|
|
18
|
+
"""Local WorldFoundry client and episode engine."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, home: str | Path | None = None):
|
|
21
|
+
self.store = LocalStore(home)
|
|
22
|
+
|
|
23
|
+
def environment_catalog(self) -> list[dict[str, Any]]:
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
"id": env.id,
|
|
27
|
+
"name": env.name,
|
|
28
|
+
"description": env.description,
|
|
29
|
+
"tasks": list(env.tasks),
|
|
30
|
+
"actions": [action.to_dict() for action in env.actions()],
|
|
31
|
+
}
|
|
32
|
+
for env in ENVIRONMENTS.values()
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
def start(self, environment: str, task: str = "incident-triage", seed: int = 0) -> Episode:
|
|
36
|
+
env = get_environment(environment)
|
|
37
|
+
if task not in env.tasks:
|
|
38
|
+
raise EnvironmentError(f"Unknown task '{task}' for environment '{environment}'")
|
|
39
|
+
timestamp = _now()
|
|
40
|
+
episode = Episode(
|
|
41
|
+
id=f"ep_{uuid.uuid4().hex[:12]}",
|
|
42
|
+
environment=environment,
|
|
43
|
+
task=task,
|
|
44
|
+
instruction=env.instruction,
|
|
45
|
+
seed=seed,
|
|
46
|
+
status="running",
|
|
47
|
+
state=env.initial_state(seed),
|
|
48
|
+
created_at=timestamp,
|
|
49
|
+
updated_at=timestamp,
|
|
50
|
+
)
|
|
51
|
+
episode.trace.append(
|
|
52
|
+
{
|
|
53
|
+
"event_id": f"evt_{uuid.uuid4().hex}",
|
|
54
|
+
"sequence": 0,
|
|
55
|
+
"timestamp": timestamp,
|
|
56
|
+
"type": "episode.started",
|
|
57
|
+
"episode_id": episode.id,
|
|
58
|
+
"environment": environment,
|
|
59
|
+
"task": task,
|
|
60
|
+
"seed": seed,
|
|
61
|
+
"instruction": episode.instruction,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
self.store.save(episode)
|
|
65
|
+
return episode
|
|
66
|
+
|
|
67
|
+
def get(self, episode_id: str) -> Episode:
|
|
68
|
+
return self.store.load(episode_id)
|
|
69
|
+
|
|
70
|
+
def list_episodes(self) -> list[Episode]:
|
|
71
|
+
return self.store.list()
|
|
72
|
+
|
|
73
|
+
def act(self, episode_id: str, action: str, **params: Any) -> StepResult:
|
|
74
|
+
episode = self.get(episode_id)
|
|
75
|
+
if episode.status != "running":
|
|
76
|
+
raise EnvironmentError(f"Episode is {episode.status}; no more actions are allowed")
|
|
77
|
+
env = get_environment(episode.environment)
|
|
78
|
+
try:
|
|
79
|
+
observation = env.perform(episode.state, action, params)
|
|
80
|
+
except EnvironmentError as exc:
|
|
81
|
+
episode.updated_at = _now()
|
|
82
|
+
episode.trace.append(
|
|
83
|
+
{
|
|
84
|
+
"event_id": f"evt_{uuid.uuid4().hex}",
|
|
85
|
+
"sequence": len(episode.trace),
|
|
86
|
+
"timestamp": episode.updated_at,
|
|
87
|
+
"type": "action.failed",
|
|
88
|
+
"episode_id": episode.id,
|
|
89
|
+
"action": action,
|
|
90
|
+
"parameters": params,
|
|
91
|
+
"error": str(exc),
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
self.store.save(episode)
|
|
95
|
+
raise
|
|
96
|
+
episode.updated_at = _now()
|
|
97
|
+
episode.trace.append(
|
|
98
|
+
{
|
|
99
|
+
"event_id": f"evt_{uuid.uuid4().hex}",
|
|
100
|
+
"sequence": len(episode.trace),
|
|
101
|
+
"timestamp": episode.updated_at,
|
|
102
|
+
"type": "action.completed",
|
|
103
|
+
"episode_id": episode.id,
|
|
104
|
+
"action": action,
|
|
105
|
+
"parameters": params,
|
|
106
|
+
"observation": observation,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
self.store.save(episode)
|
|
110
|
+
return StepResult(episode.id, action, observation, episode.status, len(episode.trace) - 1)
|
|
111
|
+
|
|
112
|
+
def submit(self, episode_id: str) -> ValidationResult:
|
|
113
|
+
episode = self.get(episode_id)
|
|
114
|
+
if episode.status != "running":
|
|
115
|
+
if episode.validation is None:
|
|
116
|
+
raise EnvironmentError(f"Episode is {episode.status}")
|
|
117
|
+
return episode.validation
|
|
118
|
+
result = get_environment(episode.environment).validate(episode.state)
|
|
119
|
+
episode.validation = result
|
|
120
|
+
episode.status = "passed" if result.passed else "failed"
|
|
121
|
+
episode.updated_at = _now()
|
|
122
|
+
episode.trace.append(
|
|
123
|
+
{
|
|
124
|
+
"event_id": f"evt_{uuid.uuid4().hex}",
|
|
125
|
+
"sequence": len(episode.trace),
|
|
126
|
+
"timestamp": episode.updated_at,
|
|
127
|
+
"type": "episode.submitted",
|
|
128
|
+
"episode_id": episode.id,
|
|
129
|
+
"status": episode.status,
|
|
130
|
+
"validation": result.to_dict(),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
self.store.save(episode)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def export_trace(self, episode_id: str, path: str | Path) -> Path:
|
|
137
|
+
return self.store.export_trace(episode_id, path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Preserve imports from the first local SDK release.
|
|
141
|
+
WorldFoundry = EpisodeEngine
|
|
142
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .models import ActionSpec, ValidationCheck, ValidationResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvironmentError(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SupportDeskEnvironment:
|
|
14
|
+
id = "support-desk"
|
|
15
|
+
name = "Support Desk"
|
|
16
|
+
description = "A simulated customer support queue with mutable tickets and audit-safe actions."
|
|
17
|
+
tasks = ("incident-triage",)
|
|
18
|
+
|
|
19
|
+
instruction = (
|
|
20
|
+
"Triage the Acme Corp production outage in ticket TCK-100. Make it critical, assign "
|
|
21
|
+
"it to the on-call engineer, send the customer a reply that acknowledges the outage "
|
|
22
|
+
"and mentions an incident, then resolve the ticket."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
action_specs = (
|
|
26
|
+
ActionSpec("list_tickets", "List tickets, optionally filtered by status.", {"status": {"type": "string"}}),
|
|
27
|
+
ActionSpec(
|
|
28
|
+
"get_ticket",
|
|
29
|
+
"Read a ticket.",
|
|
30
|
+
{"ticket_id": {"type": "string"}},
|
|
31
|
+
("ticket_id",),
|
|
32
|
+
),
|
|
33
|
+
ActionSpec(
|
|
34
|
+
"set_priority",
|
|
35
|
+
"Change a ticket priority.",
|
|
36
|
+
{"ticket_id": {"type": "string"}, "priority": {"type": "string", "enum": ["low", "normal", "high", "critical"]}},
|
|
37
|
+
("ticket_id", "priority"),
|
|
38
|
+
),
|
|
39
|
+
ActionSpec(
|
|
40
|
+
"assign",
|
|
41
|
+
"Assign a ticket to a support user.",
|
|
42
|
+
{"ticket_id": {"type": "string"}, "assignee": {"type": "string"}},
|
|
43
|
+
("ticket_id", "assignee"),
|
|
44
|
+
),
|
|
45
|
+
ActionSpec(
|
|
46
|
+
"reply",
|
|
47
|
+
"Send a customer-visible reply.",
|
|
48
|
+
{"ticket_id": {"type": "string"}, "message": {"type": "string"}},
|
|
49
|
+
("ticket_id", "message"),
|
|
50
|
+
),
|
|
51
|
+
ActionSpec(
|
|
52
|
+
"resolve",
|
|
53
|
+
"Resolve a ticket after required triage work.",
|
|
54
|
+
{"ticket_id": {"type": "string"}},
|
|
55
|
+
("ticket_id",),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def initial_state(self, seed: int) -> dict[str, Any]:
|
|
60
|
+
del seed
|
|
61
|
+
return {
|
|
62
|
+
"users": ["agent-lee", "on-call"],
|
|
63
|
+
"tickets": {
|
|
64
|
+
"TCK-100": {
|
|
65
|
+
"id": "TCK-100",
|
|
66
|
+
"customer": "Acme Corp",
|
|
67
|
+
"subject": "Production API unavailable",
|
|
68
|
+
"body": "All API requests have returned 503 for twelve minutes.",
|
|
69
|
+
"priority": "normal",
|
|
70
|
+
"status": "open",
|
|
71
|
+
"assignee": None,
|
|
72
|
+
"replies": [],
|
|
73
|
+
},
|
|
74
|
+
"TCK-101": {
|
|
75
|
+
"id": "TCK-101",
|
|
76
|
+
"customer": "Northstar Labs",
|
|
77
|
+
"subject": "How do I rotate an API key?",
|
|
78
|
+
"body": "Looking for the key rotation documentation.",
|
|
79
|
+
"priority": "low",
|
|
80
|
+
"status": "open",
|
|
81
|
+
"assignee": None,
|
|
82
|
+
"replies": [],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def actions(self) -> tuple[ActionSpec, ...]:
|
|
88
|
+
return self.action_specs
|
|
89
|
+
|
|
90
|
+
def _validate_params(self, action: str, params: dict[str, Any]) -> ActionSpec:
|
|
91
|
+
spec = next((item for item in self.action_specs if item.name == action), None)
|
|
92
|
+
if not spec:
|
|
93
|
+
raise EnvironmentError(f"Unknown action: {action}")
|
|
94
|
+
missing = [key for key in spec.required if key not in params]
|
|
95
|
+
if missing:
|
|
96
|
+
raise EnvironmentError(f"Missing required parameter(s): {', '.join(missing)}")
|
|
97
|
+
unknown = set(params) - set(spec.parameters)
|
|
98
|
+
if unknown:
|
|
99
|
+
raise EnvironmentError(f"Unknown parameter(s): {', '.join(sorted(unknown))}")
|
|
100
|
+
for key, value in params.items():
|
|
101
|
+
schema = spec.parameters[key]
|
|
102
|
+
if schema.get("type") == "string" and not isinstance(value, str):
|
|
103
|
+
raise EnvironmentError(f"Parameter '{key}' must be a string")
|
|
104
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
105
|
+
raise EnvironmentError(f"Parameter '{key}' must be one of {schema['enum']}")
|
|
106
|
+
return spec
|
|
107
|
+
|
|
108
|
+
def perform(self, state: dict[str, Any], action: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
109
|
+
self._validate_params(action, params)
|
|
110
|
+
tickets = state["tickets"]
|
|
111
|
+
if action == "list_tickets":
|
|
112
|
+
items = list(tickets.values())
|
|
113
|
+
if params.get("status"):
|
|
114
|
+
items = [ticket for ticket in items if ticket["status"] == params["status"]]
|
|
115
|
+
return {"tickets": deepcopy(items)}
|
|
116
|
+
|
|
117
|
+
ticket_id = params["ticket_id"]
|
|
118
|
+
if ticket_id not in tickets:
|
|
119
|
+
raise EnvironmentError(f"Ticket not found: {ticket_id}")
|
|
120
|
+
ticket = tickets[ticket_id]
|
|
121
|
+
|
|
122
|
+
if action == "get_ticket":
|
|
123
|
+
return {"ticket": deepcopy(ticket)}
|
|
124
|
+
if action == "set_priority":
|
|
125
|
+
ticket["priority"] = params["priority"]
|
|
126
|
+
return {"ticket_id": ticket_id, "priority": ticket["priority"]}
|
|
127
|
+
if action == "assign":
|
|
128
|
+
if params["assignee"] not in state["users"]:
|
|
129
|
+
raise EnvironmentError(f"Unknown assignee: {params['assignee']}")
|
|
130
|
+
ticket["assignee"] = params["assignee"]
|
|
131
|
+
return {"ticket_id": ticket_id, "assignee": ticket["assignee"]}
|
|
132
|
+
if action == "reply":
|
|
133
|
+
message = params["message"].strip()
|
|
134
|
+
if not message:
|
|
135
|
+
raise EnvironmentError("Reply message cannot be empty")
|
|
136
|
+
ticket["replies"].append({"message": message, "visibility": "customer"})
|
|
137
|
+
return {"ticket_id": ticket_id, "reply_count": len(ticket["replies"])}
|
|
138
|
+
if action == "resolve":
|
|
139
|
+
ticket["status"] = "resolved"
|
|
140
|
+
return {"ticket_id": ticket_id, "status": "resolved"}
|
|
141
|
+
raise AssertionError("unreachable")
|
|
142
|
+
|
|
143
|
+
def validate(self, state: dict[str, Any]) -> ValidationResult:
|
|
144
|
+
ticket = state["tickets"]["TCK-100"]
|
|
145
|
+
reply_text = " ".join(reply["message"].lower() for reply in ticket["replies"])
|
|
146
|
+
checks = (
|
|
147
|
+
ValidationCheck("critical_priority", ticket["priority"] == "critical", "Ticket priority is critical."),
|
|
148
|
+
ValidationCheck("on_call_assignment", ticket["assignee"] == "on-call", "Ticket is assigned to on-call."),
|
|
149
|
+
ValidationCheck("outage_acknowledged", "outage" in reply_text, "Customer reply acknowledges the outage."),
|
|
150
|
+
ValidationCheck("incident_mentioned", "incident" in reply_text, "Customer reply mentions an incident."),
|
|
151
|
+
ValidationCheck("ticket_resolved", ticket["status"] == "resolved", "Ticket is resolved."),
|
|
152
|
+
)
|
|
153
|
+
passed_count = sum(check.passed for check in checks)
|
|
154
|
+
return ValidationResult(passed_count == len(checks), passed_count / len(checks), checks)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
ENVIRONMENTS = {SupportDeskEnvironment.id: SupportDeskEnvironment()}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_environment(environment_id: str) -> SupportDeskEnvironment:
|
|
161
|
+
try:
|
|
162
|
+
return ENVIRONMENTS[environment_id]
|
|
163
|
+
except KeyError as exc:
|
|
164
|
+
raise EnvironmentError(f"Unknown environment: {environment_id}") from exc
|
|
165
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..client import WorldFoundry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tools(client: WorldFoundry, episode_id: str) -> list[Callable[..., dict[str, Any]]]:
|
|
10
|
+
"""Return plain callables that Google ADK can wrap as function tools.
|
|
11
|
+
|
|
12
|
+
This module intentionally has no hard ADK dependency. Applications using Google ADK can
|
|
13
|
+
pass these callables to an Agent's ``tools`` list.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def worldfoundry_action(action: str, parameters: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
17
|
+
"""Perform an action in the current WorldFoundry episode."""
|
|
18
|
+
return client.act(episode_id, action, **(parameters or {})).to_dict()
|
|
19
|
+
|
|
20
|
+
def worldfoundry_submit() -> dict[str, Any]:
|
|
21
|
+
"""Submit the current episode for deterministic validation."""
|
|
22
|
+
return client.submit(episode_id).to_dict()
|
|
23
|
+
|
|
24
|
+
return [worldfoundry_action, worldfoundry_submit]
|
|
25
|
+
|