feat(watch,server): thread events over t3 websocket to the gateway hook, t3_answer for pending questions
This commit is contained in:
1 parent
d445dad57f
commit
a98211b79f
11 files changed
+747
-92
No files matched your search
+78
-89
@@ -8,6 +8,7 @@ itself, so a plain long poll is the cheapest correct shape here.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
@@ -18,19 +19,20 @@ from fastmcp.exceptions import ToolError
|
||||
from pydantic import Field
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from t3code_mcp import t3
|
||||
from t3code_mcp import t3, views
|
||||
from t3code_mcp.config import Machine, ModelSpec
|
||||
from t3code_mcp.t3 import T3Client, T3Error
|
||||
from t3code_mcp.views import BUSY
|
||||
from t3code_mcp.watch import Tracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -40,6 +42,7 @@ class Registry:
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
clients: dict[str, T3Client] = field(default_factory=dict)
|
||||
threads: dict[str, str] = field(default_factory=dict)
|
||||
tracker: Tracker = field(default_factory=Tracker)
|
||||
|
||||
def machine(self, name: str) -> Machine:
|
||||
try:
|
||||
@@ -57,11 +60,12 @@ class Registry:
|
||||
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)
|
||||
known = self.threads.get(thread_id) or (
|
||||
self.tracker.threads[thread_id].machine
|
||||
if thread_id in self.tracker.threads
|
||||
else None
|
||||
)
|
||||
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)
|
||||
if detail is not None:
|
||||
@@ -83,14 +87,6 @@ def _title(prompt: str, title: str | None) -> str:
|
||||
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]:
|
||||
@@ -125,76 +121,35 @@ def _project_view(
|
||||
}
|
||||
|
||||
|
||||
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 build_server(
|
||||
registry: Registry,
|
||||
*,
|
||||
poll: float = 5.0,
|
||||
background: Callable[[], Coroutine[Any, Any, None]] | None = None,
|
||||
) -> FastMCP:
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_: FastMCP) -> AsyncIterator[dict[str, Any]]:
|
||||
task = asyncio.create_task(background()) if background is not None else None
|
||||
try:
|
||||
yield {}
|
||||
finally:
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
await registry.aclose()
|
||||
|
||||
|
||||
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 "
|
||||
"t3_projects(machine) → t3_dispatch, then either t3_wait (block) or just "
|
||||
"carry on: the gateway injects an event when a dispatched thread finishes, "
|
||||
"fails or asks a question (answer with t3_answer). 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."
|
||||
),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@mcp.custom_route("/healthz", methods=["GET"])
|
||||
@@ -314,6 +269,7 @@ def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
)
|
||||
await _call(client.dispatch(t3.turn_start(tid, text, title_seed=heading)))
|
||||
registry.threads[tid] = machine
|
||||
registry.tracker.track(tid, machine, target["title"], heading)
|
||||
return {
|
||||
"thread_id": tid,
|
||||
"machine": machine,
|
||||
@@ -342,8 +298,10 @@ def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
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)
|
||||
summary = views.summary(
|
||||
machine, detail, project=views.project_title(shell, detail)
|
||||
)
|
||||
summary["pending"] = views.pending_in(shell, thread_id)
|
||||
return summary
|
||||
|
||||
@mcp.tool
|
||||
@@ -372,20 +330,20 @@ def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
stalled = 0
|
||||
while True:
|
||||
thread = detail["thread"]
|
||||
state = _state(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 = _pending(shell, thread_id)
|
||||
project = _project_title(shell, detail)
|
||||
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
|
||||
timed_out = time.monotonic() - started >= timeout
|
||||
if done or timed_out:
|
||||
summary = _summary(machine, detail, project=project)
|
||||
summary = views.summary(machine, detail, project=project)
|
||||
summary["pending"] = pending
|
||||
summary["timed_out"] = timed_out and not done
|
||||
summary["waited"] = round(time.monotonic() - started, 1)
|
||||
@@ -398,6 +356,42 @@ def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
raise ToolError(msg)
|
||||
detail = refreshed
|
||||
|
||||
@mcp.tool
|
||||
async def t3_answer(
|
||||
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||
answers: Annotated[
|
||||
dict[str, str | list[str]],
|
||||
Field(
|
||||
description=(
|
||||
"question id → chosen option label (list for multi_select), "
|
||||
"ids and options from the `question` of t3_thread / the event"
|
||||
)
|
||||
),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
"""Answer the pending question of a thread; its turn resumes."""
|
||||
machine, detail = await registry.locate(thread_id)
|
||||
pending = views.question(detail)
|
||||
if pending is None or not pending.get("request_id"):
|
||||
msg = f"thread {thread_id} has no pending question"
|
||||
raise ToolError(msg)
|
||||
known = {q["id"] for q in pending["questions"]}
|
||||
unknown = set(answers) - known
|
||||
if unknown:
|
||||
msg = f"unknown question ids {sorted(unknown)}; expected {sorted(known)}"
|
||||
raise ToolError(msg)
|
||||
await _call(
|
||||
registry.client(machine).dispatch(
|
||||
t3.user_input_respond(thread_id, pending["request_id"], dict(answers))
|
||||
)
|
||||
)
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"machine": machine,
|
||||
"answered": sorted(answers),
|
||||
"state": "running",
|
||||
}
|
||||
|
||||
@mcp.tool
|
||||
async def t3_interrupt(
|
||||
thread_id: Annotated[str, Field(description="thread_id from t3_dispatch")],
|
||||
@@ -421,11 +415,6 @@ def build_server(registry: Registry, *, poll: float = 5.0) -> FastMCP:
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user