from typing import Any from pydantic import ValidationError from puppet_theater.models import Actor, SimpleToolValue, TheaterSession, ToolRequest, ToolResult from puppet_theater.trace import add_trace_event ALLOWED_TOOL_NAMES = {"inspect_prop", "consult_stage_oracle", "change_lighting"} _ALLOWED_ARGUMENTS = { "inspect_prop": {"prop"}, "consult_stage_oracle": {"question"}, "change_lighting": {"mood"}, } def run_actor_tool_request( session: TheaterSession, speaker: Actor, raw_request: ToolRequest | dict[str, Any] | None, ) -> ToolResult | None: if raw_request is None: return None requested_name = _raw_tool_name(raw_request) add_trace_event( session, "tool_requested", speaker=speaker.name, tool_name=requested_name or "unknown", ) request, validation_status = validate_tool_request(raw_request) if request is None: session.director_log.append(f"Tool request from {speaker.name} ignored: {validation_status}.") add_trace_event( session, "tool_ignored", speaker=speaker.name, tool_name=requested_name or "unknown", validation_status=validation_status, fallback_used=False, ) return None add_trace_event( session, "tool_executed", speaker=speaker.name, tool_name=request.tool_name, reason=request.reason, arguments=request.arguments, validation_status=validation_status, fallback_used=False, ) result = execute_tool(session, speaker, request) session.latest_tool_result = result session.recent_tool_results.append(result) session.recent_tool_results = session.recent_tool_results[-4:] session.director_log.append(f"Tool {request.tool_name} returned: {result.result}") add_trace_event( session, "tool_result", speaker=speaker.name, tool_name=result.tool_name, result=result.result, stage_effect=result.stage_effect, fallback_used=False, ) return result def validate_tool_request(raw_request: ToolRequest | dict[str, Any]) -> tuple[ToolRequest | None, str]: try: request = raw_request if isinstance(raw_request, ToolRequest) else ToolRequest.model_validate(raw_request) except ValidationError: return None, "invalid_tool_schema" if request.tool_name not in ALLOWED_TOOL_NAMES: return None, "invalid_tool_name" if not isinstance(request.arguments, dict): return None, "invalid_tool_arguments" allowed_arguments = _ALLOWED_ARGUMENTS[request.tool_name] unexpected_arguments = set(request.arguments) - allowed_arguments if unexpected_arguments: return None, "invalid_tool_arguments" cleaned_arguments = _clean_arguments(request.arguments) if cleaned_arguments is None: return None, "invalid_tool_arguments" return request.model_copy(update={"arguments": cleaned_arguments}), "valid" def execute_tool(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult: if request.tool_name == "inspect_prop": return _inspect_prop(session, speaker, request) if request.tool_name == "consult_stage_oracle": return _consult_stage_oracle(speaker, request) return _change_lighting(session, speaker, request) def _inspect_prop(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult: prop = _text_arg(request.arguments, "prop") or speaker.held_prop or session.latest_prop or "mystery prop" clue = _prop_clue(prop) return ToolResult( tool_name=request.tool_name, actor_name=speaker.name, reason=request.reason, arguments={"prop": prop}, result=f"The {prop} reveals {clue}.", stage_effect="prop_table_glow", ) def _consult_stage_oracle(speaker: Actor, request: ToolRequest) -> ToolResult: question = _text_arg(request.arguments, "question") or "What should the scene notice next?" clue = _oracle_clue(question) return ToolResult( tool_name=request.tool_name, actor_name=speaker.name, reason=request.reason, arguments={"question": question}, result=clue, stage_effect="oracle_haze", ) def _change_lighting(session: TheaterSession, speaker: Actor, request: ToolRequest) -> ToolResult: mood = _text_arg(request.arguments, "mood") or speaker.mood or "dramatic" lighting = f"{_safe_label(mood)}_lighting" session.stage_lighting = lighting return ToolResult( tool_name=request.tool_name, actor_name=speaker.name, reason=request.reason, arguments={"mood": mood}, result=f"Lights shift to {mood}, making every pause look intentional.", stage_effect=lighting, ) def _raw_tool_name(raw_request: ToolRequest | dict[str, Any]) -> str | None: if isinstance(raw_request, ToolRequest): return raw_request.tool_name name = raw_request.get("tool_name") if isinstance(raw_request, dict) else None return str(name)[:80] if name is not None else None def _text_arg(arguments: dict[str, SimpleToolValue], key: str) -> str | None: value = arguments.get(key) if value is None: return None return " ".join(str(value).strip().split())[:120] or None def _clean_arguments(arguments: dict[str, Any]) -> dict[str, SimpleToolValue] | None: if len(arguments) > 4: return None cleaned: dict[str, SimpleToolValue] = {} for raw_key, raw_value in arguments.items(): key = " ".join(str(raw_key).strip().split()) if not key or len(key) > 40: return None if isinstance(raw_value, str): value: SimpleToolValue = " ".join(raw_value.strip().split()) if len(value) > 120: return None elif isinstance(raw_value, bool) or raw_value is None: value = raw_value elif isinstance(raw_value, int | float): value = raw_value else: return None cleaned[key] = value return cleaned def _safe_label(value: str) -> str: label = "_".join(part for part in value.lower().split() if part.isalnum()) return label[:32] or "dramatic" def _prop_clue(prop: str) -> str: lowered = prop.lower() if "duck" in lowered: return "a squeak that points accusingly stage left" if "crown" in lowered: return "glitter arranged like tiny royal footprints" if "tomato" in lowered: return "a red smear shaped suspiciously like applause" if "scroll" in lowered: return "a footnote written in very nervous ink" if "egg" in lowered: return "a crack shaped like a dramatic reveal" return "a clue too theatrical to be accidental" def _oracle_clue(question: str) -> str: lowered = question.lower() if "finale" in lowered or "end" in lowered: return "The oracle whispers: every loose thread wants a bow." if "secret" in lowered: return "The oracle whispers: secrets knock twice before entering." if "prop" in lowered or "clue" in lowered: return "The oracle whispers: ask the smallest object why it is glowing." return "The oracle whispers: follow the spotlight, then distrust it politely."