Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions s13code/gateway.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""The only S13Code → GLC seam: ordinary authenticated HTTP."""
from __future__ import annotations

import asyncio
import os
from typing import Any

Expand All @@ -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")}

Expand Down
26 changes: 22 additions & 4 deletions s13code/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand All @@ -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'"]})