feat(t3,views,server,watch): t3 code orchestration protocol 2
This commit is contained in:
1 parent
133bc6e915
commit
f4c54b6e16
7 files changed
+448
-294
No files matched your search
+41
-49
@@ -14,6 +14,7 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
@@ -30,16 +31,17 @@ if TYPE_CHECKING:
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from t3code_mcp.t3 import RpcCall
|
||||
|
||||
TITLE_MAX = 80
|
||||
WAIT_MAX = 3600.0
|
||||
PENDING_EVERY = 6
|
||||
STALLED_POLLS = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class Registry:
|
||||
machines: dict[str, Machine]
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
rpc: Callable[[str], RpcCall] | None = None
|
||||
clients: dict[str, T3Client] = field(default_factory=dict)
|
||||
threads: dict[str, str] = field(default_factory=dict)
|
||||
tracker: Tracker = field(default_factory=Tracker)
|
||||
@@ -55,7 +57,10 @@ class Registry:
|
||||
machine = self.machine(name)
|
||||
if name not in self.clients:
|
||||
self.clients[name] = T3Client(
|
||||
machine.url, machine.token, transport=self.transport
|
||||
machine.url,
|
||||
machine.token,
|
||||
transport=self.transport,
|
||||
rpc=self.rpc(name) if self.rpc else None,
|
||||
)
|
||||
return self.clients[name]
|
||||
|
||||
@@ -67,7 +72,7 @@ class Registry:
|
||||
)
|
||||
names = [known] if known in self.machines else list(self.machines)
|
||||
for name in names:
|
||||
detail = await self.client(name).thread(thread_id, turn_limit=1)
|
||||
detail = await self.client(name).thread(thread_id)
|
||||
if detail is not None:
|
||||
self.threads[thread_id] = name
|
||||
return name, detail
|
||||
@@ -90,7 +95,7 @@ def _title(prompt: str, title: str | None) -> str:
|
||||
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)]
|
||||
allowed = [p for p in projects if machine.allows(p)]
|
||||
for project in allowed:
|
||||
if key in (project["id"], project["title"], project["workspaceRoot"]):
|
||||
return project
|
||||
@@ -107,9 +112,7 @@ def _project_view(
|
||||
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"
|
||||
)
|
||||
running = sum(1 for t in own if views.shell_state(t) in BUSY)
|
||||
default = project.get("defaultModelSelection")
|
||||
return {
|
||||
"id": project["id"],
|
||||
@@ -201,7 +204,7 @@ def build_server(
|
||||
return [
|
||||
_project_view(p, shell["threads"])
|
||||
for p in shell["projects"]
|
||||
if not p.get("deletedAt") and spec.allows(p)
|
||||
if spec.allows(p)
|
||||
]
|
||||
|
||||
@mcp.tool
|
||||
@@ -273,12 +276,12 @@ def build_server(
|
||||
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":
|
||||
if views.shell_state(existing) in BUSY:
|
||||
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()))
|
||||
client.dispatch(t3.message_dispatch(tid, text, selection.selection()))
|
||||
)
|
||||
else:
|
||||
tid = t3.new_id()
|
||||
@@ -287,7 +290,9 @@ def build_server(
|
||||
t3.thread_create(tid, target["id"], heading, selection.selection())
|
||||
)
|
||||
)
|
||||
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
|
||||
await _call(
|
||||
client.dispatch(t3.message_dispatch(tid, text, title_seed=heading))
|
||||
)
|
||||
registry.threads[tid] = machine
|
||||
registry.tracker.track(tid, machine, target["title"], heading, watch=watch)
|
||||
return {
|
||||
@@ -338,24 +343,22 @@ def build_server(
|
||||
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)
|
||||
int,
|
||||
Field(
|
||||
description="How many recent runs (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)
|
||||
machine, detail = await registry.locate(thread_id)
|
||||
shell = await _call(registry.client(machine).shell())
|
||||
summary = views.summary(
|
||||
machine, detail, project=views.project_title(shell, detail)
|
||||
machine, detail, project=views.project_title(shell, detail), turns=turns
|
||||
)
|
||||
summary["pending"] = views.pending_in(shell, thread_id)
|
||||
summary["pending"] = views.pending_detail(detail["projection"])
|
||||
return summary
|
||||
|
||||
@mcp.tool
|
||||
@@ -378,23 +381,13 @@ def build_server(
|
||||
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
|
||||
project = views.project_title(await _call(client.shell()), detail)
|
||||
while True:
|
||||
thread = detail["thread"]
|
||||
state = views.state(thread)
|
||||
session = thread.get("session") or {}
|
||||
status = session.get("status")
|
||||
if polls % PENDING_EVERY == 0:
|
||||
shell = await _call(client.shell())
|
||||
pending = views.pending_in(shell, thread_id)
|
||||
project = views.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
|
||||
projection = detail["projection"]
|
||||
state = views.state(projection)
|
||||
pending = views.pending_detail(projection)
|
||||
stuck = views.latest_run(projection) is None and views.error(projection)
|
||||
done = state not in BUSY or pending is not None or stuck
|
||||
timed_out = time.monotonic() - started >= timeout
|
||||
if done or timed_out:
|
||||
summary = views.summary(machine, detail, project=project)
|
||||
@@ -403,8 +396,7 @@ def build_server(
|
||||
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))
|
||||
refreshed = await _call(client.thread(thread_id))
|
||||
if refreshed is None:
|
||||
msg = f"thread {thread_id} vanished from {machine}"
|
||||
raise ToolError(msg)
|
||||
@@ -425,7 +417,7 @@ def build_server(
|
||||
) -> dict[str, Any]:
|
||||
"""Answer the pending question of a thread; its turn resumes."""
|
||||
machine, detail = await registry.locate(thread_id)
|
||||
pending = views.question(detail)
|
||||
pending = views.question(detail["projection"])
|
||||
if pending is None or not pending.get("request_id"):
|
||||
msg = f"thread {thread_id} has no pending question"
|
||||
raise ToolError(msg)
|
||||
@@ -436,7 +428,9 @@ def build_server(
|
||||
raise ToolError(msg)
|
||||
await _call(
|
||||
registry.client(machine).dispatch(
|
||||
t3.user_input_respond(thread_id, pending["request_id"], dict(answers))
|
||||
t3.runtime_request_respond(
|
||||
thread_id, pending["request_id"], dict(answers)
|
||||
)
|
||||
)
|
||||
)
|
||||
return {
|
||||
@@ -452,17 +446,15 @@ def build_server(
|
||||
) -> 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":
|
||||
latest = views.latest_run(detail["projection"])
|
||||
if latest is None or latest["status"] not in BUSY:
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"machine": machine,
|
||||
"state": latest.get("state", "idle"),
|
||||
"state": latest["status"] if latest else "idle",
|
||||
}
|
||||
await _call(
|
||||
registry.client(machine).dispatch(
|
||||
t3.turn_interrupt(thread_id, latest.get("turnId"))
|
||||
)
|
||||
registry.client(machine).dispatch(t3.run_interrupt(thread_id, latest["id"]))
|
||||
)
|
||||
return {"thread_id": thread_id, "machine": machine, "state": "interrupting"}
|
||||
|
||||
@@ -474,6 +466,6 @@ async def _call(awaitable: Any) -> Any:
|
||||
return await awaitable
|
||||
except T3Error as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
except (httpx.HTTPError, websockets.WebSocketException, OSError) as exc:
|
||||
msg = f"T3 unreachable: {exc}"
|
||||
raise ToolError(msg) from exc
|
||||
Reference in new issue
Block a user