feat(*): t3code-mcp connector - machines, projects, dispatch, wait, interrupt
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import logging
|
||||
|
||||
from t3code_mcp.config import Settings, load_machines
|
||||
from t3code_mcp.server import Registry, build_server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(message)s")
|
||||
settings = Settings()
|
||||
machines = load_machines(settings.config)
|
||||
logging.getLogger(__name__).info(
|
||||
"t3code-mcp: machines %s, listening on %s:%s",
|
||||
list(machines),
|
||||
settings.host,
|
||||
settings.port,
|
||||
)
|
||||
server = build_server(Registry(machines))
|
||||
server.run(transport="http", host=settings.host, port=settings.port, path="/mcp")
|
||||
@@ -0,0 +1,3 @@
|
||||
from t3code_mcp import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Machines and their project allowlists, from TOML; tokens only from env.
|
||||
|
||||
```toml
|
||||
[machines.mac]
|
||||
url = "http://100.65.207.48:3773"
|
||||
token_env = "T3_MAC_TOKEN"
|
||||
projects = ["t3-smoke", "/Users/h/projects/openprise/beaver/*"]
|
||||
model = "claudeAgent/claude-opus-5"
|
||||
options = { effort = "high", contextWindow = "1m" }
|
||||
```
|
||||
|
||||
`projects` entries are `fnmatch` patterns matched against a project's title
|
||||
and its workspace root. A machine without `model` uses the project default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ModelSpec(BaseModel):
|
||||
"""`instance/model` plus provider options, as T3's `ModelSelection`."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
instance: str
|
||||
model: str
|
||||
options: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str, options: dict[str, str] | None = None) -> Self:
|
||||
instance, sep, model = text.partition("/")
|
||||
if not sep or not instance or not model:
|
||||
msg = f"model must be `instance/model`, got {text!r}"
|
||||
raise ValueError(msg)
|
||||
return cls(instance=instance, model=model, options=options or {})
|
||||
|
||||
@classmethod
|
||||
def from_selection(cls, selection: dict[str, Any]) -> Self:
|
||||
options = {o["id"]: str(o["value"]) for o in selection.get("options") or []}
|
||||
return cls(
|
||||
instance=selection["instanceId"], model=selection["model"], options=options
|
||||
)
|
||||
|
||||
def selection(self) -> dict[str, Any]:
|
||||
wire: dict[str, Any] = {"instanceId": self.instance, "model": self.model}
|
||||
if self.options:
|
||||
wire["options"] = [{"id": k, "value": v} for k, v in self.options.items()]
|
||||
return wire
|
||||
|
||||
def __str__(self) -> str:
|
||||
suffix = "".join(f" {k}={v}" for k, v in self.options.items())
|
||||
return f"{self.instance}/{self.model}{suffix}"
|
||||
|
||||
|
||||
class Machine(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
url: str
|
||||
token_env: str
|
||||
projects: tuple[str, ...] = ()
|
||||
model: ModelSpec | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _fold_model(cls, data: Any) -> Any:
|
||||
if isinstance(data, dict) and isinstance(data.get("model"), str):
|
||||
data = {
|
||||
**data,
|
||||
"model": ModelSpec.parse(data["model"], data.pop("options", None)),
|
||||
}
|
||||
return data
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
token = os.environ.get(self.token_env, "").strip()
|
||||
if not token:
|
||||
msg = f"machine {self.name!r}: env {self.token_env} is empty"
|
||||
raise ValueError(msg)
|
||||
return token
|
||||
|
||||
def allows(self, project: dict[str, Any]) -> bool:
|
||||
candidates = (project.get("title", ""), project.get("workspaceRoot", ""))
|
||||
return any(
|
||||
fnmatch.fnmatchcase(c, pattern)
|
||||
for pattern in self.projects
|
||||
for c in candidates
|
||||
)
|
||||
|
||||
|
||||
def load_machines(path: Path) -> dict[str, Machine]:
|
||||
with path.open("rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
machines = {
|
||||
name: Machine(name=name, **fields)
|
||||
for name, fields in raw.get("machines", {}).items()
|
||||
}
|
||||
if not machines:
|
||||
msg = f"{path}: no [machines.<name>] sections"
|
||||
raise ValueError(msg)
|
||||
for machine in machines.values():
|
||||
_ = machine.token
|
||||
return machines
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="T3CODE_MCP_")
|
||||
|
||||
config: Path = Path("t3code.toml")
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
@@ -0,0 +1,436 @@
|
||||
"""MCP tools over one or more T3 Code servers.
|
||||
|
||||
`t3_wait` blocks on purpose: Claude Code has no MCP Tasks support and moves
|
||||
any tool call still running after two minutes into a background task by
|
||||
itself, so a plain long poll is the cheapest correct shape here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from t3code_mcp import t3
|
||||
from t3code_mcp.config import Machine, ModelSpec
|
||||
from t3code_mcp.t3 import T3Client, T3Error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
|
||||
TITLE_MAX = 80
|
||||
TEXT_MAX = 6000
|
||||
TOOLS_MAX = 12
|
||||
WAIT_MAX = 3600.0
|
||||
PENDING_EVERY = 6
|
||||
BUSY = frozenset({"running", "starting"})
|
||||
STALLED_POLLS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class Registry:
|
||||
machines: dict[str, Machine]
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
clients: dict[str, T3Client] = field(default_factory=dict)
|
||||
threads: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def machine(self, name: str) -> Machine:
|
||||
try:
|
||||
return self.machines[name]
|
||||
except KeyError:
|
||||
msg = f"unknown machine {name!r}; known: {', '.join(self.machines)}"
|
||||
raise ToolError(msg) from None
|
||||
|
||||
def client(self, name: str) -> T3Client:
|
||||
machine = self.machine(name)
|
||||
if name not in self.clients:
|
||||
self.clients[name] = T3Client(
|
||||
machine.url, machine.token, transport=self.transport
|
||||
)
|
||||
return self.clients[name]
|
||||
|
||||
async def locate(self, thread_id: str) -> tuple[str, dict[str, Any]]:
|
||||
names = (
|
||||
[self.threads[thread_id]]
|
||||
if thread_id in self.threads
|
||||
else list(self.machines)
|
||||
)
|
||||
for name in names:
|
||||
detail = await self.client(name).thread(thread_id, turn_limit=1)
|
||||
if detail is not None:
|
||||
self.threads[thread_id] = name
|
||||
return name, detail
|
||||
msg = f"thread {thread_id} not found on {', '.join(names)}"
|
||||
raise ToolError(msg)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
for client in self.clients.values():
|
||||
await client.aclose()
|
||||
self.clients.clear()
|
||||
|
||||
|
||||
def _title(prompt: str, title: str | None) -> str:
|
||||
text = (title or prompt).strip().splitlines()[0].strip()
|
||||
if len(text) > TITLE_MAX:
|
||||
text = text[:TITLE_MAX].rsplit(" ", 1)[0] + "…"
|
||||
return text or "t3code-mcp"
|
||||
|
||||
|
||||
def _state(thread: dict[str, Any]) -> str:
|
||||
latest = thread.get("latestTurn")
|
||||
if latest:
|
||||
return latest["state"]
|
||||
messages = thread.get("messages", [])
|
||||
return "starting" if messages and messages[-1]["role"] == "user" else "idle"
|
||||
|
||||
|
||||
def _find_project(
|
||||
machine: Machine, projects: list[dict[str, Any]], key: str
|
||||
) -> dict[str, Any]:
|
||||
allowed = [p for p in projects if not p.get("deletedAt") and machine.allows(p)]
|
||||
for project in allowed:
|
||||
if key in (project["id"], project["title"], project["workspaceRoot"]):
|
||||
return project
|
||||
names = ", ".join(p["title"] for p in allowed) or "(none allowlisted)"
|
||||
msg = f"project {key!r} is not available on {machine.name}; allowed: {names}"
|
||||
raise ToolError(msg)
|
||||
|
||||
|
||||
def _project_view(
|
||||
project: dict[str, Any], threads: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
own = [
|
||||
t
|
||||
for t in threads
|
||||
if t["projectId"] == project["id"] and not t.get("archivedAt")
|
||||
]
|
||||
running = sum(
|
||||
1 for t in own if (t.get("latestTurn") or {}).get("state") == "running"
|
||||
)
|
||||
default = project.get("defaultModelSelection")
|
||||
return {
|
||||
"id": project["id"],
|
||||
"title": project["title"],
|
||||
"path": project["workspaceRoot"],
|
||||
"default_model": str(ModelSpec.from_selection(default)) if default else None,
|
||||
"threads": len(own),
|
||||
"running": running,
|
||||
}
|
||||
|
||||
|
||||
def _summary(
|
||||
machine: str, detail: dict[str, Any], *, project: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
thread = detail["thread"]
|
||||
latest = thread.get("latestTurn") or {}
|
||||
session = thread.get("session") or {}
|
||||
messages = [
|
||||
{"role": m["role"], "text": m["text"][-TEXT_MAX:], "streaming": m["streaming"]}
|
||||
for m in thread.get("messages", [])
|
||||
if m.get("text")
|
||||
]
|
||||
tools = [
|
||||
{
|
||||
"summary": a["summary"],
|
||||
"detail": str((a.get("payload") or {}).get("detail", ""))[:200],
|
||||
}
|
||||
for a in thread.get("activities", [])
|
||||
if a.get("kind") == "tool.completed"
|
||||
][-TOOLS_MAX:]
|
||||
files = [
|
||||
{"path": f["path"], "kind": f["kind"], "+": f["additions"], "-": f["deletions"]}
|
||||
for c in thread.get("checkpoints", [])[-1:]
|
||||
for f in c.get("files", [])
|
||||
]
|
||||
context = next(
|
||||
(
|
||||
a["payload"]
|
||||
for a in reversed(thread.get("activities", []))
|
||||
if a.get("kind") == "context-window.updated"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"thread_id": thread["id"],
|
||||
"machine": machine,
|
||||
"project": project or thread["projectId"],
|
||||
"title": thread["title"],
|
||||
"model": str(ModelSpec.from_selection(thread["modelSelection"])),
|
||||
"state": _state(thread),
|
||||
"turn_id": latest.get("turnId"),
|
||||
"session": session.get("status"),
|
||||
"error": session.get("lastError"),
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"files": files,
|
||||
"context_tokens": (context or {}).get("usedTokens"),
|
||||
}
|
||||
|
||||
|
||||
def _pending(shell: dict[str, Any], thread_id: str) -> dict[str, bool] | None:
|
||||
for thread in shell.get("threads", []):
|
||||
if thread["id"] == thread_id:
|
||||
flags = {
|
||||
"approval": bool(thread.get("hasPendingApprovals")),
|
||||
"user_input": bool(thread.get("hasPendingUserInput")),
|
||||
"plan": bool(thread.get("hasActionableProposedPlan")),
|
||||
}
|
||||
return flags if any(flags.values()) else None
|
||||
return None
|
||||
|
||||
|
||||
def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
mcp = FastMCP(
|
||||
"t3code",
|
||||
instructions=(
|
||||
"Coding threads on Бобёр's machines through T3 Code. Flow: t3_machines → "
|
||||
"t3_projects(machine) → t3_dispatch → t3_wait / t3_thread. Prompts run "
|
||||
"autonomously (full-access, no approvals): say what to change, what to "
|
||||
"check, what not to touch, and that the answer should end with a report."
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.custom_route("/healthz", methods=["GET"])
|
||||
async def healthz(_: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "ok", "machines": list(registry.machines)})
|
||||
|
||||
@mcp.tool
|
||||
async def t3_machines() -> list[dict[str, Any]]:
|
||||
"""Machines with a T3 Code server: reachable now or not, project allowlist."""
|
||||
result = []
|
||||
for name, machine in registry.machines.items():
|
||||
entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"url": machine.url,
|
||||
"projects": machine.projects,
|
||||
}
|
||||
try:
|
||||
descriptor = await registry.client(name).descriptor()
|
||||
except (httpx.HTTPError, T3Error) as exc:
|
||||
entry.update(online=False, error=str(exc)[:200])
|
||||
else:
|
||||
entry.update(
|
||||
online=True,
|
||||
label=descriptor.get("label"),
|
||||
version=descriptor.get("serverVersion"),
|
||||
os=(descriptor.get("platform") or {}).get("os"),
|
||||
)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
@mcp.tool
|
||||
async def t3_projects(
|
||||
machine: Annotated[str, Field(description="Machine name from t3_machines")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Allowlisted projects on a machine: default model, thread counts."""
|
||||
spec = registry.machine(machine)
|
||||
shell = await _call(registry.client(machine).shell())
|
||||
return [
|
||||
_project_view(p, shell["threads"])
|
||||
for p in shell["projects"]
|
||||
if not p.get("deletedAt") and spec.allows(p)
|
||||
]
|
||||
|
||||
@mcp.tool
|
||||
async def t3_dispatch(
|
||||
machine: Annotated[str, Field(description="Machine name from t3_machines")],
|
||||
project: Annotated[
|
||||
str, Field(description="Project title, path or id from t3_projects")
|
||||
],
|
||||
prompt: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The task for the coding agent, complete and self-contained"
|
||||
),
|
||||
],
|
||||
title: Annotated[
|
||||
str | None,
|
||||
Field(description="Thread title; default - first line of the prompt"),
|
||||
] = None,
|
||||
model: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
description=(
|
||||
"`instance/model`, e.g. claudeAgent/claude-opus-5; "
|
||||
"default from config or the project"
|
||||
)
|
||||
),
|
||||
] = None,
|
||||
thread_id: Annotated[
|
||||
str | None,
|
||||
Field(description="Continue this existing thread instead of creating one"),
|
||||
] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a coding thread on a machine (or follow up in one); returns at once.
|
||||
|
||||
The thread runs in the background: t3_wait blocks until it finishes,
|
||||
t3_thread peeks without blocking.
|
||||
"""
|
||||
text = prompt.strip()
|
||||
if not text:
|
||||
msg = "prompt is empty"
|
||||
raise ToolError(msg)
|
||||
spec = registry.machine(machine)
|
||||
client = registry.client(machine)
|
||||
shell = await _call(client.shell())
|
||||
target = _find_project(spec, shell["projects"], project)
|
||||
if model:
|
||||
selection = ModelSpec.parse(
|
||||
model, spec.model.options if spec.model else None
|
||||
)
|
||||
elif spec.model:
|
||||
selection = spec.model
|
||||
elif target.get("defaultModelSelection"):
|
||||
selection = ModelSpec.from_selection(target["defaultModelSelection"])
|
||||
else:
|
||||
msg = f"no model: set `model` in config for {machine} or pass model="
|
||||
raise ToolError(msg)
|
||||
heading = _title(text, title)
|
||||
if thread_id:
|
||||
existing = next((t for t in shell["threads"] if t["id"] == thread_id), None)
|
||||
if existing is None or existing["projectId"] != target["id"]:
|
||||
msg = f"thread {thread_id} is not in {target['title']} on {machine}"
|
||||
raise ToolError(msg)
|
||||
if (existing.get("latestTurn") or {}).get("state") == "running":
|
||||
msg = f"thread {thread_id} is running; t3_wait or t3_interrupt first"
|
||||
raise ToolError(msg)
|
||||
tid = thread_id
|
||||
await _call(
|
||||
client.dispatch(t3.turn_start(tid, text, selection.selection()))
|
||||
)
|
||||
else:
|
||||
tid = t3.new_id()
|
||||
await _call(
|
||||
client.dispatch(
|
||||
t3.thread_create(tid, target["id"], heading, selection.selection())
|
||||
)
|
||||
)
|
||||
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
|
||||
registry.threads[tid] = machine
|
||||
return {
|
||||
"thread_id": tid,
|
||||
"machine": machine,
|
||||
"project": target["title"],
|
||||
"path": target["workspaceRoot"],
|
||||
"title": heading,
|
||||
"model": str(selection),
|
||||
"state": "running",
|
||||
}
|
||||
|
||||
@mcp.tool
|
||||
async def t3_thread(
|
||||
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||
turns: Annotated[
|
||||
int, Field(description="How many recent user turns to include", ge=1, le=20)
|
||||
] = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""State of a thread without waiting.
|
||||
|
||||
Turn state, last messages, tools run, files changed, pending questions.
|
||||
"""
|
||||
machine, _ = await registry.locate(thread_id)
|
||||
client = registry.client(machine)
|
||||
detail = await _call(client.thread(thread_id, turn_limit=turns))
|
||||
shell = await _call(client.shell())
|
||||
if detail is None:
|
||||
msg = f"thread {thread_id} vanished from {machine}"
|
||||
raise ToolError(msg)
|
||||
summary = _summary(machine, detail, project=_project_title(shell, detail))
|
||||
summary["pending"] = _pending(shell, thread_id)
|
||||
return summary
|
||||
|
||||
@mcp.tool
|
||||
async def t3_wait(
|
||||
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||
timeout: Annotated[ # noqa: ASYNC109 - the tool's contract, not asyncio's
|
||||
float,
|
||||
Field(
|
||||
description="Seconds to wait before giving the current state back",
|
||||
ge=5,
|
||||
le=WAIT_MAX,
|
||||
),
|
||||
] = 900,
|
||||
) -> dict[str, Any]:
|
||||
"""Block until the turn finishes, asks a question, or timeout runs out.
|
||||
|
||||
Ends on completed / interrupted / error, on a pending approval or
|
||||
user-input request, or after `timeout` seconds; returns the t3_thread view.
|
||||
"""
|
||||
machine, detail = await registry.locate(thread_id)
|
||||
client = registry.client(machine)
|
||||
started = time.monotonic()
|
||||
polls = 0
|
||||
pending: dict[str, bool] | None = None
|
||||
project: str | None = None
|
||||
stalled = 0
|
||||
while True:
|
||||
thread = detail["thread"]
|
||||
state = _state(thread)
|
||||
session = thread.get("session") or {}
|
||||
status = session.get("status")
|
||||
if polls % PENDING_EVERY == 0:
|
||||
shell = await _call(client.shell())
|
||||
pending = _pending(shell, thread_id)
|
||||
project = _project_title(shell, detail)
|
||||
failed = status == "error" or bool(session.get("lastError"))
|
||||
stalled = stalled + 1 if state == "starting" and status == "stopped" else 0
|
||||
busy = (state in BUSY or status == "starting") and stalled < STALLED_POLLS
|
||||
done = not busy or failed or pending is not None
|
||||
timed_out = time.monotonic() - started >= timeout
|
||||
if done or timed_out:
|
||||
summary = _summary(machine, detail, project=project)
|
||||
summary["pending"] = pending
|
||||
summary["timed_out"] = timed_out and not done
|
||||
summary["waited"] = round(time.monotonic() - started, 1)
|
||||
return summary
|
||||
await asyncio.sleep(poll)
|
||||
polls += 1
|
||||
refreshed = await _call(client.thread(thread_id, turn_limit=1))
|
||||
if refreshed is None:
|
||||
msg = f"thread {thread_id} vanished from {machine}"
|
||||
raise ToolError(msg)
|
||||
detail = refreshed
|
||||
|
||||
@mcp.tool
|
||||
async def t3_interrupt(
|
||||
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||
) -> dict[str, Any]:
|
||||
"""Interrupt the running turn; continue later via t3_dispatch(thread_id=...)."""
|
||||
machine, detail = await registry.locate(thread_id)
|
||||
latest = detail["thread"].get("latestTurn") or {}
|
||||
if latest.get("state") != "running":
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"machine": machine,
|
||||
"state": latest.get("state", "idle"),
|
||||
}
|
||||
await _call(
|
||||
registry.client(machine).dispatch(
|
||||
t3.turn_interrupt(thread_id, latest.get("turnId"))
|
||||
)
|
||||
)
|
||||
return {"thread_id": thread_id, "machine": machine, "state": "interrupting"}
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
def _project_title(shell: dict[str, Any], detail: dict[str, Any]) -> str | None:
|
||||
project_id = detail["thread"]["projectId"]
|
||||
return next((p["title"] for p in shell["projects"] if p["id"] == project_id), None)
|
||||
|
||||
|
||||
async def _call(awaitable: Any) -> Any:
|
||||
try:
|
||||
return await awaitable
|
||||
except T3Error as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
msg = f"T3 unreachable: {exc}"
|
||||
raise ToolError(msg) from exc
|
||||
@@ -0,0 +1,153 @@
|
||||
"""HTTP client for one T3 Code server (`packages/contracts/src/environmentHttp.ts`).
|
||||
|
||||
Four routes are used: the unauthenticated descriptor, the shell read-model
|
||||
(projects + threads without bodies), one thread with a turn window, and
|
||||
`dispatch` with a `ClientOrchestrationCommand`. The WebSocket RPC
|
||||
(`terminal.*` and friends) is deliberately not wrapped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
DESCRIPTOR = "/.well-known/t3/environment"
|
||||
SHELL = "/api/orchestration/shell"
|
||||
THREADS = "/api/orchestration/threads"
|
||||
DISPATCH = "/api/orchestration/dispatch"
|
||||
|
||||
RUNTIME_MODE = "full-access"
|
||||
INTERACTION_MODE = "default"
|
||||
PROBE_TIMEOUT = 5.0
|
||||
|
||||
|
||||
class T3Error(Exception):
|
||||
def __init__(self, status: int, body: Any) -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
detail = (
|
||||
f"{body.get('code')}: {body.get('reason') or body.get('message')}"
|
||||
if isinstance(body, dict)
|
||||
else str(body)[:300]
|
||||
)
|
||||
super().__init__(f"T3 responded {status} - {detail}")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def thread_create(
|
||||
thread_id: str, project_id: str, title: str, selection: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "thread.create",
|
||||
"commandId": new_id(),
|
||||
"threadId": thread_id,
|
||||
"projectId": project_id,
|
||||
"title": title,
|
||||
"modelSelection": selection,
|
||||
"runtimeMode": RUNTIME_MODE,
|
||||
"interactionMode": INTERACTION_MODE,
|
||||
"branch": None,
|
||||
"worktreePath": None,
|
||||
"createdAt": now_iso(),
|
||||
}
|
||||
|
||||
|
||||
def turn_start(
|
||||
thread_id: str,
|
||||
text: str,
|
||||
selection: dict[str, Any] | None = None,
|
||||
title_seed: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
command: dict[str, Any] = {
|
||||
"type": "thread.turn.start",
|
||||
"commandId": new_id(),
|
||||
"threadId": thread_id,
|
||||
"message": {
|
||||
"messageId": new_id(),
|
||||
"role": "user",
|
||||
"text": text,
|
||||
"attachments": [],
|
||||
},
|
||||
"runtimeMode": RUNTIME_MODE,
|
||||
"interactionMode": INTERACTION_MODE,
|
||||
"createdAt": now_iso(),
|
||||
}
|
||||
if selection is not None:
|
||||
command["modelSelection"] = selection
|
||||
if title_seed:
|
||||
command["titleSeed"] = title_seed
|
||||
return command
|
||||
|
||||
|
||||
def turn_interrupt(thread_id: str, turn_id: str | None = None) -> dict[str, Any]:
|
||||
command: dict[str, Any] = {
|
||||
"type": "thread.turn.interrupt",
|
||||
"commandId": new_id(),
|
||||
"threadId": thread_id,
|
||||
"createdAt": now_iso(),
|
||||
}
|
||||
if turn_id:
|
||||
command["turnId"] = turn_id
|
||||
return command
|
||||
|
||||
|
||||
class T3Client:
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
token: str,
|
||||
*,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.url = url.rstrip("/")
|
||||
self._http = httpx.AsyncClient(
|
||||
base_url=self.url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._http.aclose()
|
||||
|
||||
async def descriptor(self) -> dict[str, Any]:
|
||||
response = await self._http.get(
|
||||
DESCRIPTOR, headers={"Authorization": ""}, timeout=PROBE_TIMEOUT
|
||||
)
|
||||
return self._json(response)
|
||||
|
||||
async def shell(self) -> dict[str, Any]:
|
||||
return self._json(await self._http.get(SHELL))
|
||||
|
||||
async def thread(
|
||||
self, thread_id: str, turn_limit: int | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
params = {"turnLimit": turn_limit} if turn_limit else None
|
||||
response = await self._http.get(f"{THREADS}/{thread_id}", params=params)
|
||||
if response.status_code == httpx.codes.NOT_FOUND:
|
||||
return None
|
||||
return self._json(response)
|
||||
|
||||
async def dispatch(self, command: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._json(await self._http.post(DISPATCH, json=command))
|
||||
|
||||
@staticmethod
|
||||
def _json(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
body: Any = response.json()
|
||||
except ValueError:
|
||||
body = response.text
|
||||
if response.is_error or not isinstance(body, dict):
|
||||
raise T3Error(response.status_code, body)
|
||||
return body
|
||||
Reference in New Issue
Block a user