返回 JoyAI-Echo
director_callback.py
根目录 / echo_longvideo / Director_Agent / nanobot / channels / director_callback.py
1 """Director callback channel.
2
3 Receives remote director/Echo callbacks, applies workspace state changes, and
4 emits runtime/UI notifications without projecting the callback into the LLM
5 conversation.
6 """
7
8 from __future__ import annotations
9
10 import asyncio
11 import hmac
12 import json
13 from pathlib import Path
14 from typing import Any, Callable
15
16 from loguru import logger
17
18 from nanobot.agent.tools.director import (
19 GenerateEchoShotTool,
20 apply_echo_generate_shot_callback,
21 apply_merge_shot_callback,
22 )
23 from nanobot.bus.events import OutboundMessage, RuntimeEvent
24 from nanobot.bus.queue import MessageBus
25 from nanobot.channels.base import BaseChannel
26 from nanobot.config.schema import Base, ToolsConfig
27
28
29 class DirectorCallbackConfig(Base):
30 """HTTP callback channel for remote director/Echo jobs."""
31
32 enabled: bool = False
33 host: str = "127.0.0.1"
34 port: int = 18791
35 path_prefix: str = "/api/director"
36 secret: str = ""
37
38
39 class DirectorCallbackChannel(BaseChannel):
40 """Receive remote director callbacks as system-level runtime events."""
41
42 name = "director_callback"
43 display_name = "Director Callback"
44
45 def __init__(
46 self,
47 config: Any,
48 bus: MessageBus,
49 *,
50 workspace: Path | None = None,
51 tools_config: ToolsConfig | None = None,
52 memory_review_runner: Callable[..., Any] | None = None,
53 memory_approval_runner: Callable[..., Any] | None = None,
54 shot_generation_runner: Callable[..., Any] | None = None,
55 ):
56 if isinstance(config, dict):
57 config = DirectorCallbackConfig.model_validate(config)
58 super().__init__(config, bus)
59 self.config: DirectorCallbackConfig = config
60 self.workspace = workspace or Path(".")
61 self._tools_config = tools_config or ToolsConfig()
62 self._runner: Any | None = None
63 self._memory_review_lock = asyncio.Lock()
64 if memory_review_runner is None:
65 from nanobot.director.memory_coordinator import run_memory_review_from_config
66
67 memory_review_runner = run_memory_review_from_config
68 if memory_approval_runner is None:
69 from nanobot.director.r2v_memory_workflow import (
70 auto_approve_review_and_prepare_next,
71 )
72
73 memory_approval_runner = auto_approve_review_and_prepare_next
74 self._memory_review_runner = memory_review_runner
75 self._memory_approval_runner = memory_approval_runner
76 self._shot_generation_runner = (
77 shot_generation_runner or self._submit_memory_next_shot
78 )
79
80 @classmethod
81 def default_config(cls) -> dict[str, Any]:
82 return DirectorCallbackConfig().model_dump(by_alias=True)
83
84 async def start(self) -> None:
85 try:
86 from aiohttp import web
87 except ImportError:
88 logger.error("director_callback requires aiohttp. Install with: pip install 'echo-director-agent[api]'")
89 return
90
91 self._running = True
92 app = self.create_app()
93 runner = web.AppRunner(app)
94 self._runner = runner
95 await runner.setup()
96 site = web.TCPSite(runner, self.config.host, self.config.port)
97 await site.start()
98 logger.info(
99 "Director callback channel listening on http://{}:{}{}",
100 self.config.host,
101 self.config.port,
102 self._path_prefix(),
103 )
104 try:
105 while self._running:
106 await asyncio.sleep(1)
107 finally:
108 await runner.cleanup()
109 self._runner = None
110
111 async def stop(self) -> None:
112 self._running = False
113 if self._runner is not None:
114 try:
115 await self._runner.cleanup()
116 except Exception as exc:
117 logger.warning("director_callback cleanup failed: {}", exc)
118 self._runner = None
119
120 async def send(self, msg: OutboundMessage) -> None:
121 logger.debug("director_callback has no outbound delivery target: {}", msg.metadata)
122
123 def create_app(self) -> Any:
124 from aiohttp import web
125
126 app = web.Application(client_max_size=20 * 1024 * 1024)
127 prefix = self._path_prefix()
128 app.router.add_get("/health", self._handle_health)
129 app.router.add_post(f"{prefix}/echo-generate-shot/callback", self._handle_echo_generate_callback)
130 app.router.add_post(f"{prefix}/merge-shot/callback", self._handle_merge_callback)
131 return app
132
133 def _path_prefix(self) -> str:
134 raw = (self.config.path_prefix or "/api/director").strip()
135 if not raw.startswith("/"):
136 raw = f"/{raw}"
137 return raw.rstrip("/") or "/api/director"
138
139 def _authorized(self, request: Any) -> bool:
140 secret = (self.config.secret or "").strip()
141 if not secret:
142 return True
143 supplied = (
144 request.headers.get("X-Nanobot-Director-Secret")
145 or request.headers.get("X-Nanobot-Auth")
146 or ""
147 ).strip()
148 return bool(supplied) and hmac.compare_digest(supplied, secret)
149
150 async def _parse_body(self, request: Any) -> dict[str, Any] | Any:
151 from aiohttp import web
152
153 if not self._authorized(request):
154 return web.json_response({"error": "Unauthorized"}, status=401)
155 try:
156 body = await request.json()
157 except Exception:
158 return web.json_response({"error": "Invalid JSON body"}, status=400)
159 if not isinstance(body, dict):
160 return web.json_response({"error": "Callback body must be a JSON object"}, status=400)
161 return body
162
163 async def _handle_health(self, request: Any) -> Any:
164 from aiohttp import web
165
166 return web.json_response({"status": "ok"})
167
168 async def _handle_echo_generate_callback(self, request: Any) -> Any:
169 return await self._handle_callback(
170 request,
171 operation="generate_echo_shot",
172 apply_callback=apply_echo_generate_shot_callback,
173 result_fields=("result_urls", "updated_shots"),
174 )
175
176 async def _handle_merge_callback(self, request: Any) -> Any:
177 return await self._handle_callback(
178 request,
179 operation="merge_shot",
180 apply_callback=apply_merge_shot_callback,
181 result_fields=("final_output",),
182 )
183
184 async def _handle_callback(
185 self,
186 request: Any,
187 *,
188 operation: str,
189 apply_callback: Callable[..., dict[str, Any]],
190 result_fields: tuple[str, ...],
191 ) -> Any:
192 from aiohttp import web
193
194 parsed = await self._parse_body(request)
195 if isinstance(parsed, web.Response):
196 return parsed
197
198 try:
199 result = apply_callback(
200 self.workspace,
201 parsed,
202 tools_config=self._tools_config,
203 )
204 except ValueError as exc:
205 return web.json_response({"error": str(exc)}, status=400)
206 except Exception:
207 logger.exception("director_callback: failed to apply {}", operation)
208 return web.json_response({"error": f"Failed to apply {operation} callback"}, status=500)
209
210 logger.info(
211 "director_callback received operation={} work_id={} status={}",
212 operation,
213 result.get("work_id"),
214 result.get("status"),
215 )
216 if result.get("duplicate"):
217 return web.json_response(
218 {
219 "status": "ok",
220 "operation": operation,
221 "job_id": result.get("job_id"),
222 "work_id": result.get("work_id"),
223 "duplicate": True,
224 "runtime_event": False,
225 "workplace_notified": False,
226 }
227 )
228 self._schedule_memory_review(operation, parsed, result)
229 await self._publish_runtime_event(operation, parsed, result)
230 workplace_notified = await self._publish_workplace_updated(parsed, result)
231 logger.info(
232 "director_callback done operation={} workplace_notified={}",
233 operation,
234 workplace_notified,
235 )
236
237 response: dict[str, Any] = {
238 "status": "ok",
239 "operation": operation,
240 "job_id": result.get("job_id"),
241 "work_id": result.get("work_id"),
242 "runtime_event": True,
243 "workplace_notified": workplace_notified,
244 }
245 for field in result_fields:
246 response[field] = result.get(field) or ([] if field.endswith("s") else None)
247 return web.json_response(response)
248
249 def _memory_review_enabled(self) -> bool:
250 return self._tools_config.memory_review.enabled
251
252 def _memory_review_auto_approve_enabled(self) -> bool:
253 return self._tools_config.memory_review.auto_approve
254
255 def _work_is_auto_generate(self, work_id: str) -> bool:
256 if not work_id:
257 return False
258 state_path = self.workspace / "director" / "works" / work_id / "state.json"
259 try:
260 data = json.loads(state_path.read_text(encoding="utf-8"))
261 except FileNotFoundError:
262 return False
263 except (OSError, json.JSONDecodeError) as exc:
264 logger.error(
265 "director_callback failed to read auto_generate work_id={} error={}",
266 work_id,
267 exc,
268 )
269 return False
270 return isinstance(data, dict) and bool(data.get("auto_generate"))
271
272 def _schedule_memory_review(
273 self, operation: str, body: dict[str, Any], result: dict[str, Any]
274 ) -> None:
275 if (
276 operation != "generate_echo_shot"
277 or str(result.get("status") or "") != "completed"
278 or not self._memory_review_enabled()
279 ):
280 return
281 updated = result.get("updated_shots")
282 if not isinstance(updated, list) or not updated:
283 return
284 try:
285 shot_id = int(updated[0]["shot_id"])
286 except (KeyError, TypeError, ValueError):
287 return
288 work_id = str(result.get("work_id") or body.get("work_id") or "").strip()
289 if work_id:
290 auto_select = (
291 self._work_is_auto_generate(work_id)
292 or self._memory_review_auto_approve_enabled()
293 )
294 if not auto_select:
295 try:
296 from nanobot.director.memory_coordinator import (
297 initialize_memory_review_method_prompt,
298 )
299
300 initialize_memory_review_method_prompt(
301 workspace=self.workspace,
302 work_id=work_id,
303 shot_id=shot_id,
304 )
305 except Exception:
306 logger.exception(
307 "director_callback: failed to initialize memory method prompt "
308 "work_id={} shot_id={}",
309 work_id,
310 shot_id,
311 )
312 return
313 try:
314 from nanobot.director.memory_coordinator import mark_memory_review_selecting
315
316 mark_memory_review_selecting(
317 workspace=self.workspace,
318 work_id=work_id,
319 shot_id=shot_id,
320 )
321 except Exception:
322 logger.exception(
323 "director_callback: failed to mark memory selecting "
324 "work_id={} shot_id={}",
325 work_id,
326 shot_id,
327 )
328 asyncio.create_task(
329 self._run_memory_review(
330 body=body,
331 result=result,
332 work_id=work_id,
333 shot_id=shot_id,
334 )
335 )
336
337 async def _run_memory_review(
338 self,
339 *,
340 body: dict[str, Any],
341 result: dict[str, Any],
342 work_id: str,
343 shot_id: int,
344 ) -> None:
345 try:
346 async with self._memory_review_lock:
347 review = await asyncio.to_thread(
348 self._memory_review_runner,
349 workspace=self.workspace,
350 work_id=work_id,
351 shot_id=shot_id,
352 )
353 if not isinstance(review, dict):
354 return
355 auto_generate = self._work_is_auto_generate(work_id)
356 if auto_generate or self._memory_review_auto_approve_enabled():
357 next_shot_id = await asyncio.to_thread(
358 self._memory_approval_runner,
359 workspace=self.workspace,
360 work_id=work_id,
361 shot_id=shot_id,
362 )
363 # Auto-generate lets the WebSocket workflow continue the next
364 # shot. Submitting here races generate_all.
365 if not auto_generate and next_shot_id is not None:
366 await asyncio.to_thread(
367 self._shot_generation_runner,
368 workspace=self.workspace,
369 work_id=work_id,
370 shot_id=int(next_shot_id),
371 body=body,
372 result=result,
373 )
374 except Exception as exc:
375 logger.exception(
376 "director_callback: memory selection failed work_id={} shot_id={}",
377 work_id,
378 shot_id,
379 )
380 try:
381 from nanobot.director.memory_coordinator import (
382 initialize_memory_review_method_prompt,
383 )
384
385 await asyncio.to_thread(
386 initialize_memory_review_method_prompt,
387 workspace=self.workspace,
388 work_id=work_id,
389 shot_id=shot_id,
390 error=f"Memory review failed: {exc}",
391 )
392 except Exception:
393 logger.exception(
394 "director_callback: failed to persist memory selection error "
395 "work_id={} shot_id={}",
396 work_id,
397 shot_id,
398 )
399 finally:
400 await self._publish_workplace_updated(body, result)
401
402 def _submit_memory_next_shot(
403 self,
404 *,
405 workspace: Path,
406 work_id: str,
407 shot_id: int,
408 body: dict[str, Any],
409 result: dict[str, Any],
410 ) -> dict[str, Any] | None:
411 """Submit the Memory-prepared next shot unless it is already in flight."""
412 shot_path = (
413 workspace / "director" / "works" / work_id / "shots"
414 / f"shot_{shot_id:03d}.json"
415 )
416 try:
417 shot = json.loads(shot_path.read_text(encoding="utf-8"))
418 except (OSError, json.JSONDecodeError) as exc:
419 raise ValueError(f"next shot {shot_id} is unavailable") from exc
420 if not isinstance(shot, dict):
421 raise ValueError(f"next shot {shot_id} is invalid")
422 if str(shot.get("status") or "") in {
423 "queued",
424 "generated",
425 "review_pass",
426 "approved",
427 }:
428 return None
429
430 references = shot.get("planned_reference_shot_ids") or []
431 if not isinstance(references, list):
432 references = []
433 reference_ids = [int(value) for value in references]
434 selection_note = str(shot.get("reference_selection_note") or "").strip() or None
435 channel_name = str(result.get("channel") or body.get("channel") or "").strip()
436 chat_id = str(result.get("chat_id") or body.get("chat_id") or "").strip()
437 session_key = str(
438 result.get("session_key") or body.get("session_key") or ""
439 ).strip()
440 tool = GenerateEchoShotTool(
441 workspace=workspace,
442 tools_config=self._tools_config,
443 )
444 if channel_name and chat_id:
445 tool.set_context(
446 channel_name,
447 chat_id,
448 effective_key=session_key or f"{channel_name}:{chat_id}",
449 )
450 return tool.apply_generate(
451 work_id,
452 shot_id,
453 reference_ids,
454 selection_note=selection_note,
455 )
456
457 async def _publish_runtime_event(
458 self,
459 operation: str,
460 body: dict[str, Any],
461 result: dict[str, Any],
462 ) -> None:
463 session_key = str(result.get("session_key") or body.get("session_key") or "").strip() or None
464 channel = str(result.get("channel") or body.get("channel") or "").strip() or None
465 chat_id = str(result.get("chat_id") or body.get("chat_id") or "").strip() or None
466 await self.bus.publish_runtime(
467 RuntimeEvent(
468 kind="director_remote_result",
469 source=self.name,
470 session_key=session_key,
471 channel=channel,
472 chat_id=chat_id,
473 payload={
474 "operation": operation,
475 "work_id": result.get("work_id") or body.get("work_id"),
476 "job_id": result.get("job_id") or body.get("job_id"),
477 "status": body.get("status") or "completed",
478 "result": result,
479 },
480 )
481 )
482
483 async def _publish_workplace_updated(
484 self,
485 body: dict[str, Any],
486 result: dict[str, Any],
487 ) -> bool:
488 channel = str(result.get("channel") or body.get("channel") or "").strip()
489 chat_id = str(result.get("chat_id") or body.get("chat_id") or "").strip()
490 session_key = str(result.get("session_key") or body.get("session_key") or "").strip()
491 if channel != "websocket" or not chat_id:
492 logger.debug(
493 "director_callback workplace push SKIPPED channel={} chat_id={}",
494 channel or "-",
495 chat_id or "-",
496 )
497 return False
498 work_id = result.get("work_id") or body.get("work_id")
499 media = result.get("video_paths") or result.get("result_urls") or result.get("media") or []
500 logger.info(
501 "director_callback workplace push SENT work_id={} session={} chat_id={} media_count={}",
502 work_id or "-",
503 session_key,
504 chat_id,
505 len(media) if isinstance(media, list) else 0,
506 )
507 await self.bus.publish_outbound(
508 OutboundMessage(
509 channel="websocket",
510 chat_id=chat_id,
511 content="",
512 media=media if isinstance(media, list) else [],
513 metadata={
514 "_workplace_event": "updated",
515 "session_key": session_key,
516 **({"work_id": work_id} if isinstance(work_id, str) and work_id.strip() else {}),
517 },
518 )
519 )
520 return True
521
521 lines PYTHON