Skip to content

Commit ed7352f

Browse files
ehsan6shaclaude
andcommitted
routes: POST /troubleshoot/tree — deterministic tree endpoint (Phase 1.c)
Closes the engine + trees foundation by wiring the tree runner into a public route. Mirrors POST /troubleshoot exactly for session + event_buffer + SSE + resume behaviour — only the event source differs (tree runner vs LLM bridge). Endpoint contract (POST /troubleshoot/tree): - body: {scenario_id: str, session_id?: str} - response: text/event-stream - 200 = SSE stream of events (same vocabulary as /troubleshoot: thought, tool_call, tool_result, verdict, recommended_action, error) - 404 = scenario_id not in registry - 409 = session_already_active (use /troubleshoot/resume to reattach) - 422 = missing or extra fields - 503 = tree_runner_unavailable (trees did not load at container start; check logs) Wiring (src/app.py lifespan): - After action_executor loads, attempt to load tree registry from BLOX_AI_TREES_DIR (default /etc/fula/blox-ai/trees). - Cross-validate against the diag tool set (known_tools) + the action whitelist (tier_2_names + tier_3_names from LoadedWhitelist) before constructing the TreeRunner. - Soft-fail: if the dir is missing or any tree fails validation, log + set app.state.tree_runner = None; /troubleshoot/tree returns 503 but other endpoints still work. - Companion fula-ota commit adds the bind mount + the env var. Driver function (_drive_tree_into_buffer): - Counterpart to _drive_generator_into_buffer used by /troubleshoot for the LLM bridge. - Walks the tree's async iterator, writes each event into the sessions event_buffer via session.append_event. - Same try/except/finally shape: CancelledError -> mark_done + re-raise; other exceptions -> error event + mark_done. 8 endpoint tests (test_troubleshoot_tree.py): - happy path returns SSE + verdict event - diag-calling tree emits tool_call + tool_result + verdict - caller-supplied session_id is preserved - 503 when tree_runner is None - 404 on unknown scenario_id - 422 on missing scenario_id - 422 on extra fields (pydantic extra: forbid) - 409 on concurrent POST against an active session Full suite: 459/459 (was 451; +8 net). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e0bd87d commit ed7352f

3 files changed

Lines changed: 373 additions & 1 deletion

File tree

src/app.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121

2222
from src.runtime.mock_backend import MockBackend
2323
from src.runtime.runbook_loader import RunbookLoader
24+
from src.runtime.tree_dsl import TreeValidationError, load_tree_registry
25+
from src.runtime.tree_runner import TreeRunner
2426
from src.session.manager import SessionManager
2527
from src.tools.approval_token import ApprovalTokenSigner
26-
from src.tools.diag_impls import RealDiagExecutor
28+
from src.tools.diag_impls import RealDiagExecutor, known_tools
2729
from src.tools.executor import ActionExecutor, WhitelistError, load_whitelist
2830
from src.schemas import SchemaRegistry
2931
from src.routes import cancel, diag, execute, feedback, health, pending, troubleshoot
@@ -100,6 +102,46 @@ async def lifespan(app: FastAPI):
100102
runbook_loader=None, # rewired below once runbook_loader exists
101103
)
102104

105+
# Phase 1.c: deterministic tree runner. Loads YAML trees from
106+
# BLOX_AI_TREES_DIR; cross-validates against the diag tool set +
107+
# the action whitelist loaded above. Soft-fail in dev — if the
108+
# tree dir is missing or trees fail to load, /troubleshoot/tree
109+
# returns 503 but other endpoints still work.
110+
trees_dir = os.environ.get(
111+
"BLOX_AI_TREES_DIR",
112+
"/etc/fula/blox-ai/trees",
113+
)
114+
app.state.tree_runner = None
115+
if os.path.isdir(trees_dir):
116+
try:
117+
known_action_names = set()
118+
if app.state.action_executor is not None:
119+
wl = app.state.action_executor.whitelist
120+
# LoadedWhitelist exposes tier_2_names / tier_3_names
121+
# as frozensets per src/tools/executor.py.
122+
known_action_names |= set(wl.tier_2_names)
123+
known_action_names |= set(wl.tier_3_names)
124+
diag_short = {t.removeprefix("diag/") for t in known_tools()}
125+
registry = load_tree_registry(
126+
trees_dir,
127+
known_diag_tools=diag_short,
128+
known_action_names=known_action_names,
129+
)
130+
app.state.tree_runner = TreeRunner(
131+
trees=registry,
132+
tool_executor=app.state.tool_executor,
133+
)
134+
logger.info(
135+
"tree_runner wired with %d trees: %s",
136+
len(registry), sorted(registry.keys()),
137+
)
138+
except TreeValidationError as e:
139+
logger.error("tree registry load failed: %s; /troubleshoot/tree will 503", e)
140+
else:
141+
logger.info(
142+
"trees_dir %s not present; /troubleshoot/tree will 503", trees_dir,
143+
)
144+
103145
# C5: in-memory session registry for /troubleshoot conversations.
104146
# 30-min sliding TTL, 50-session cap, LRU eviction. Lost on container
105147
# restart by design (matches HMAC approval-secret rotation).

src/routes/troubleshoot.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ class TroubleshootRequest(BaseModel):
4444
session_id: str | None = Field(default=None, min_length=1, max_length=128)
4545

4646

47+
class TreeRequest(BaseModel):
48+
"""POST /troubleshoot/tree body (Phase 1.c).
49+
50+
The deterministic-tree counterpart to /troubleshoot. Same SSE event
51+
vocabulary; same session/event_buffer/resume infrastructure; same
52+
/execute-action approval flow for recommended actions. Difference:
53+
the events come from a YAML-authored tree walker, not the LLM.
54+
55+
Used by the app's quick-start buttons (the user picked a scenario
56+
explicitly) and by the classifier endpoint (the LLM mapped a
57+
free-text prompt to a known scenario_id)."""
58+
model_config = {"extra": "forbid"}
59+
60+
scenario_id: str = Field(min_length=1, max_length=64)
61+
session_id: str | None = Field(default=None, min_length=1, max_length=128)
62+
63+
4764
class UserReplyRequest(BaseModel):
4865
"""POST /troubleshoot/user-reply body. Mirrors fula-ota's
4966
user_reply_request.schema.json shape."""
@@ -190,6 +207,118 @@ async def _stream_from_buffer(
190207
await session.cond.wait()
191208

192209

210+
async def _drive_tree_into_buffer(
211+
session: SessionState,
212+
tree_runner,
213+
scenario_id: str,
214+
) -> None:
215+
"""Phase 1.c counterpart to _drive_generator_into_buffer. Walks the
216+
tree and writes each emitted event into the session's buffer. The
217+
SSE consumer (and any resume callers) read the buffer; they don't
218+
talk to the tree runner directly. Apps/box's resume protocol
219+
works unchanged because the event vocabulary is the same."""
220+
try:
221+
async for event in tree_runner.run(scenario_id):
222+
await session.append_event(event)
223+
except asyncio.CancelledError:
224+
await session.mark_done()
225+
raise
226+
except Exception as e: # noqa: BLE001
227+
logger.exception("tree runner failed session=%s scenario=%s",
228+
session.session_id, scenario_id)
229+
try:
230+
await session.append_event({
231+
"type": "error",
232+
"code": "tree_runner_crashed",
233+
"message": str(e)[:200],
234+
"recoverable": False,
235+
})
236+
except Exception:
237+
pass
238+
finally:
239+
await session.mark_done()
240+
241+
242+
@router.post("/troubleshoot/tree")
243+
async def troubleshoot_tree(req: TreeRequest, request: Request) -> Response:
244+
"""Phase 1.c — deterministic tree runner endpoint.
245+
246+
Mirrors POST /troubleshoot's session/buffer/SSE plumbing exactly;
247+
the only difference is the generator (tree runner vs LLM bridge).
248+
503 when the tree runner failed to load at startup (missing
249+
BLOX_AI_TREES_DIR or YAML validation error).
250+
"""
251+
tree_runner = getattr(request.app.state, "tree_runner", None)
252+
if tree_runner is None:
253+
return JSONResponse(
254+
status_code=503,
255+
content=_error_body(
256+
"tree_runner_unavailable",
257+
"trees did not load at startup; check container logs",
258+
),
259+
)
260+
# 404 on unknown scenario — caller bug, not a transient error.
261+
if req.scenario_id not in tree_runner.trees:
262+
return JSONResponse(
263+
status_code=404,
264+
content=_error_body(
265+
"unknown_scenario_id",
266+
f"available: {sorted(tree_runner.trees.keys())}",
267+
),
268+
)
269+
270+
session_mgr = request.app.state.session_manager
271+
if req.session_id:
272+
session = session_mgr.get(req.session_id)
273+
if session is None:
274+
session = session_mgr.create(session_id=req.session_id)
275+
else:
276+
session = session_mgr.create()
277+
278+
if (
279+
session.generator_task is not None
280+
and not getattr(session.generator_task, "done", lambda: True)()
281+
and not session.generator_done
282+
):
283+
return JSONResponse(
284+
status_code=409,
285+
content=_error_body(
286+
"session_already_active",
287+
"use GET /troubleshoot/resume?session_id=...&from=N to reattach",
288+
),
289+
)
290+
291+
# Reset buffer state for the new run (matches /troubleshoot).
292+
session.event_buffer = []
293+
session.next_seq = 0
294+
session.dropped_count = 0
295+
session.generator_done = False
296+
session.consumer_generation += 1
297+
async with session.cond:
298+
session.cond.notify_all()
299+
300+
session.generator_task = asyncio.create_task(
301+
_drive_tree_into_buffer(session, tree_runner, req.scenario_id),
302+
name=f"blox-ai-tree-{session.session_id}-{req.scenario_id}",
303+
)
304+
305+
async def sse_stream():
306+
try:
307+
async for chunk in _stream_from_buffer(session, from_seq=0):
308+
yield chunk
309+
finally:
310+
session_mgr.touch(session.session_id)
311+
312+
return StreamingResponse(
313+
sse_stream(),
314+
media_type="text/event-stream",
315+
headers={
316+
"Cache-Control": "no-cache",
317+
"X-Accel-Buffering": "no",
318+
},
319+
)
320+
321+
193322
@router.post("/troubleshoot")
194323
async def troubleshoot(req: TroubleshootRequest, request: Request) -> Response:
195324
backend = request.app.state.backend

0 commit comments

Comments
 (0)