diff --git a/s13code/gateway.py b/s13code/gateway.py index c073ae8..e55830e 100644 --- a/s13code/gateway.py +++ b/s13code/gateway.py @@ -1,6 +1,7 @@ """The only S13Code → GLC seam: ordinary authenticated HTTP.""" from __future__ import annotations +import asyncio import os from typing import Any @@ -27,9 +28,17 @@ async def complete(self, prompt: str, system: str, *, session: str | None = None # independently metered gemini_1..N key pool; S13Code never sees keys. if provider := os.getenv("S13_GATEWAY_PROVIDER"): payload["provider"] = provider - response = await self._client.post(f"{self.base_url}/v1/chat", json=payload) - if response.status_code >= 400: - raise RuntimeError(f"GLC /v1/chat returned {response.status_code}: {response.text[:500]}") + + max_retries = 5 + for attempt in range(1, max_retries + 1): + response = await self._client.post(f"{self.base_url}/v1/chat", json=payload) + if response.status_code in (429, 503) and attempt < max_retries: + await asyncio.sleep(2 ** attempt) + continue + if response.status_code >= 400: + raise RuntimeError(f"GLC /v1/chat returned {response.status_code}: {response.text[:500]}") + break + body = response.json() return {"text": body.get("text", ""), "provider": body.get("provider"), "model": body.get("model")} diff --git a/s13code/planner.py b/s13code/planner.py index c167b2b..fe6cbd2 100644 --- a/s13code/planner.py +++ b/s13code/planner.py @@ -35,7 +35,7 @@ def __init__(self, llm: TextLLM, fallback: FallbackPlanner, *, goal: str, roles: async def plan(self, graph: GraphSnapshot, event: Event) -> GraphPatch: try: reply = await self.llm(self._prompt(graph, event), "Return only valid GraphPatch JSON.") - patch = self._parse(reply.get("text", "")) + patch = self._parse(reply.get("text", ""), graph) self.last_selection = {"mode": "llm", "provider": reply.get("provider"), "model": reply.get("model"), "event": event.sequence} return patch @@ -44,29 +44,47 @@ async def plan(self, graph: GraphSnapshot, event: Event) -> GraphPatch: "reason": f"{type(error).__name__}: {error}"} return await self.fallback.plan(graph, event) - def _parse(self, text: str) -> GraphPatch: - data = json.loads(text) + def _parse(self, text: str, graph: GraphSnapshot) -> GraphPatch: + text = text.strip() + if text.startswith("```json"): + text = text[7:] + elif text.startswith("```"): + text = text[3:] + if text.endswith("```"): + text = text[:-3] + data = json.loads(text.strip()) if not isinstance(data, dict) or set(data).difference({"add", "connect", "cancel", "wait", "resume", "finish", "reason"}): raise ValueError("invalid GraphPatch object") add: list[TaskSpec] = [] + add_ids: set[str] = set() for raw in data.get("add", []): if not isinstance(raw, dict) or set(raw).difference({"id", "role", "input", "metadata"}): raise ValueError("invalid task object") node_id, role = raw.get("id"), raw.get("role") if not isinstance(node_id, str) or not _ID.fullmatch(node_id) or role not in self.roles: raise ValueError("invalid node id or unsupported role") + if node_id in graph.nodes or node_id in add_ids: + raise ValueError(f"duplicate node id: {node_id}") + add_ids.add(node_id) task_input, metadata = raw.get("input", {}), raw.get("metadata", {}) if not isinstance(task_input, dict) or not isinstance(metadata, dict): raise ValueError("task input/metadata must be objects") add.append(TaskSpec(node_id, role, task_input, {**metadata, "agent": role})) + all_ids = set(graph.nodes).union(add_ids) def id_list(name: str) -> tuple[str, ...]: value = data.get(name, []) if not isinstance(value, list) or not all(isinstance(x, str) and _ID.fullmatch(x) for x in value): raise ValueError(f"invalid {name}") + if any(x not in all_ids for x in value): + raise ValueError(f"{name} references unknown task") return tuple(value) edges = data.get("connect", []) if not isinstance(edges, list) or not all(isinstance(e, list) and len(e) == 2 and all(isinstance(x, str) and _ID.fullmatch(x) for x in e) for e in edges): raise ValueError("invalid connect") + if any(e[0] == e[1] for e in edges): + raise ValueError("a task cannot depend on itself") + if any(e[0] not in all_ids or e[1] not in all_ids for e in edges): + raise ValueError("edge references unknown task") if not isinstance(data.get("finish", False), bool) or not isinstance(data.get("reason", ""), str): raise ValueError("invalid finish/reason") return GraphPatch(tuple(add), tuple((e[0], e[1]) for e in edges), id_list("cancel"), id_list("wait"), @@ -78,4 +96,4 @@ def _prompt(self, graph: GraphSnapshot, event: Event) -> str: "allowed_roles": sorted(self.roles), "patch_schema": {"add": [{"id": "safe_id", "role": "allowed_role", "input": {}, "metadata": {"agent": "role"}}], "connect": [["parent", "child"]], "cancel": [], "wait": [], "resume": [], "finish": False, "reason": "short"}, - "rules": ["emit only next useful work", "roles never call tools directly", "return JSON only"]}) + "rules": ["emit only next useful work", "roles never call tools directly", "return JSON only", "the final node must have id 'answer'"]})