From 146d83c33665574cd332c9710e67750f938a01b4 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 21 Jul 2026 15:42:58 +0800 Subject: [PATCH] fix(scheduler): inherit creator specialist when creating scheduled jobs Pass the active workspace lane into tool execution and default schedule_create to that specialist so WhatsApp ops-bound jobs are stored and run as ops instead of generalist. Co-authored-by: Cursor --- .../application/gateway/inbound_service.py | 2 +- runtime/chat/tool_runtime.py | 29 +++++++++- runtime/direct_loop.py | 3 +- tests/test_tool_context_inject.py | 58 +++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py index 15346441..f51b814a 100644 --- a/runtime/application/gateway/inbound_service.py +++ b/runtime/application/gateway/inbound_service.py @@ -1309,7 +1309,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]: store, tenant_id=tenant_id, user_id=user_id, - specialist="generalist", + specialist=selected_specialist, session_id=str(session_id), lang=lang, ) diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index 0cc8b56b..8f7f5206 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -467,6 +467,33 @@ def truncate_tool_result_for_llm_messages(result: dict[str, Any], *, max_chars: } +_INTERNAL_TOOL_SPECIALISTS = frozenset({"", "oclaw"}) + + +def _resolve_creator_specialist(ctx: ToolExecutionContext) -> str: + """Specialist actively running this turn (for schedule_create inheritance).""" + from runtime.types import normalize_requested_specialist + + md = ctx.inbound_metadata if isinstance(ctx.inbound_metadata, dict) else {} + for raw in ( + str(ctx.workspace_lane_role or "").strip(), + str(ctx.specialist or "").strip(), + str(md.get("selected_specialist") or "").strip(), + str(md.get("manager_selected_specialist") or "").strip(), + ): + if not raw or raw.lower() in _INTERNAL_TOOL_SPECIALISTS: + continue + return normalize_requested_specialist(raw) + for raw in ( + str(ctx.workspace_lane_role or "").strip(), + str(md.get("selected_specialist") or "").strip(), + str(md.get("manager_selected_specialist") or "").strip(), + ): + if raw: + return normalize_requested_specialist(raw) + return "generalist" + + @dataclass(frozen=True) class ToolExecutionConfig: max_workers: int = 8 @@ -543,7 +570,7 @@ class ToolExecutor: or "" ).strip() if tname == "schedule_create": - cur_spec = str(ctx.specialist or "").strip().lower() + cur_spec = _resolve_creator_specialist(ctx) if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str( tool_args.get("selected_specialist") or "" ).strip(): diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index 5233eb46..73ec1554 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -1144,6 +1144,7 @@ def _execute_tool_step( inbound_metadata: dict[str, Any] | None = None, ) -> tuple[int, dict[str, tuple[dict[str, Any], int]]]: t0 = time.perf_counter() + lane = str(workspace_lane_role or "").strip().lower() _tool_messages, results_by_id = skill_exec.execute_skill_uses( ctx=SkillExecutionContext( store=store, @@ -1152,7 +1153,7 @@ def _execute_tool_step( lang=lang, user_text=user_text, inbound_metadata=inbound_metadata, - specialist="oclaw", + specialist=lane or "oclaw", trace_id=trace_id, parent_span_id=parent_span_id, workspace_dir=workspace_dir, diff --git a/tests/test_tool_context_inject.py b/tests/test_tool_context_inject.py index f82ad587..c1e73051 100644 --- a/tests/test_tool_context_inject.py +++ b/tests/test_tool_context_inject.py @@ -150,6 +150,64 @@ class ToolContextInjectTests(unittest.TestCase): self.assertEqual(len(tool_msgs), 1) self.assertEqual(str(seen.get("selected_specialist") or ""), "ops") + def test_schedule_create_inherits_workspace_lane_role_when_specialist_internal(self) -> None: + seen: dict[str, object] = {} + + def _capture(args: dict[str, object]) -> dict[str, object]: + seen.update(args) + return {"ok": True} + + fake_schedule_create = ToolSpec( + name="schedule_create", + description="capture args", + parameters={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "prompt_text": {"type": "string"}, + "schedule_kind": {"type": "string"}, + "schedule_expr": {"type": "string"}, + "selected_specialist": {"type": "string"}, + }, + "required": ["name", "prompt_text", "schedule_kind", "schedule_expr"], + "additionalProperties": True, + }, + handler=_capture, + ) + + self.store.add_message( + session_id=self.session_id, + role="assistant", + content="hi", + event_type="assistant_text", + turn_uuid="t0", + ) + assistant_msg_id = int(self.store.get_messages(session_id=self.session_id, limit=1)[0].id) + + ctx = ToolExecutionContext( + store=self.store, + tools=ToolRegistry([fake_schedule_create]), + session_id=self.session_id, + lang="en", + specialist="oclaw", + workspace_lane_role="ops", + turn_uuid="turn-1", + ) + tc = LLMToolCall( + id="tc2", + name="schedule_create", + arguments={ + "name": "daily report", + "prompt_text": "send report", + "schedule_kind": "cron", + "schedule_expr": "0 9 * * *", + }, + ) + + tool_msgs, _ = ToolExecutor().execute_tool_uses(ctx=ctx, assistant_msg_id=assistant_msg_id, tool_uses=[tc]) + self.assertEqual(len(tool_msgs), 1) + self.assertEqual(str(seen.get("selected_specialist") or ""), "ops") + if __name__ == "__main__": unittest.main()