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.
@@ -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
+
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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,2 @@
1
+ """Framework integration helpers."""
2
+
@@ -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
+