diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py index da8fee2e..73c48106 100644 --- a/runtime/chat/tool_runtime.py +++ b/runtime/chat/tool_runtime.py @@ -505,6 +505,21 @@ class ToolExecutor: path_policy_tenant_id=ctx.path_policy_tenant_id, path_policy_user_id=ctx.path_policy_user_id, ) + # Default specialist context: if the model did not explicitly provide a specialist for + # schedule tools, inherit from the current executor specialist. + # + # This makes scheduled jobs created by ops/generalist reflect the creator specialist by default, + # which is important for "created-by specialist" semantics and for later scheduled runs. + try: + tname = str(tc.name or "") + if tname == "schedule_create": + cur_spec = str(ctx.specialist or "").strip().lower() + if cur_spec and not str(tool_args.get("specialist") or "").strip() and not str( + tool_args.get("selected_specialist") or "" + ).strip(): + tool_args["selected_specialist"] = cur_spec + except Exception: + pass tool_args = filter_arguments_to_schema(tool.parameters, tool_args) ok, v_err = validate_tool_arguments(tool.parameters, tool_args) diff --git a/tests/test_tool_context_inject.py b/tests/test_tool_context_inject.py index 474ae5b0..f82ad587 100644 --- a/tests/test_tool_context_inject.py +++ b/tests/test_tool_context_inject.py @@ -7,6 +7,9 @@ from pathlib import Path from runtime.tools.context_inject import enrich_tool_arguments from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool +from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor +from runtime.tools.base import ToolRegistry, ToolSpec +from svc.llm.transports.base import LLMToolCall from svc.persistence.assistant_store import reset_assistant_store_singleton from svc.persistence.sqlite_store import SqliteStore @@ -88,6 +91,65 @@ class ToolContextInjectTests(unittest.TestCase): self.assertNotIn("user_id", filtered) self.assertIn("name", filtered) + def test_schedule_create_inherits_executor_specialist_when_missing(self) -> None: + # Tool args from the model commonly omit "specialist". In that case, schedule_create should + # receive selected_specialist inherited from executor context (ops/generalist/...). + 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="ops", + turn_uuid="turn-1", + ) + tc = LLMToolCall( + id="tc1", + name="schedule_create", + arguments={ + "name": "drink water", + "prompt_text": "drink water", + "schedule_kind": "interval", + "schedule_expr": "300", + }, + ) + + 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()