From 62d5fcfddca83143fb252e9120d850d5d31ac8fa Mon Sep 17 00:00:00 2001 From: Kiki McArron Date: Tue, 29 Sep 2026 13:04:40 +0200 Subject: [PATCH 1/4] Implement system tool --- statek/__init__.py | 3 +- statek/system.py | 17 +++++++ tests/test_notification_tools.py | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/test_notification_tools.py diff --git a/statek/__init__.py b/statek/__init__.py index e824b47..06e2823 100644 --- a/statek/__init__.py +++ b/statek/__init__.py @@ -52,7 +52,7 @@ from .executors.post_processor import FinalCheck, PostProcessor from .system import (tool, subtask, docstr, get_any, get_all, error_handler, is_valid_error_handler, docs_style, find_sub_task_handler, - find_tools) + get_pending_notifications, find_tools) from .shared_context import ( ContextCategory, ContextCategoryDict, @@ -187,6 +187,7 @@ def init(settings: Optional[StatekSettings] = None, restricted: bool = True) -> "create_new_job", "create_sub_task", "find_sub_task_handler", + "get_pending_notifications", "ContextCategory", "ContextCategoryDict", "ContextVar", diff --git a/statek/system.py b/statek/system.py index c777a80..cccf8ba 100644 --- a/statek/system.py +++ b/statek/system.py @@ -26,6 +26,7 @@ from .dbzero_restricted import as_unrestricted from .future import get_any_future, get_all_future, FutureResult from .docstring import parse_tool_docstring, format_docstring +from .executors.chat_log_item import ChatLogItem from .utils import find_locals, get_current_agent_name, get_current_job, _statek_ctx_scope @@ -451,6 +452,22 @@ def wrapper(*args, **kwargs): return _decorate(f) +@tool(system=True) +def get_pending_notifications(**kwargs) -> Iterable[ChatLogItem]: # pylint: disable=unused-argument + """Return an ordered snapshot of the current job's unprocessed notifications. + + The snapshot contains pending chat log items, including subtask results, + without moving them into the job's history or consuming the queue. + + Raises: + RuntimeError: When called without a current job. + """ + job = get_current_job() + if job is None: + raise RuntimeError("get_pending_notifications requires a current job") + return list(job._pending_notifications()) # pylint: disable=protected-access + + @tool(system=True) def find_sub_task_handler(id: Optional[Any] = None, **kwargs) -> Any: # pylint: disable=redefined-builtin,unused-argument """Return a subtask handler from the current job notification history. diff --git a/tests/test_notification_tools.py b/tests/test_notification_tools.py new file mode 100644 index 0000000..8ee3f5d --- /dev/null +++ b/tests/test_notification_tools.py @@ -0,0 +1,82 @@ +"""Behavioral tests for job notification system tools.""" + +# pylint: disable=no-member,protected-access + +import pytest + +from statek import get_pending_notifications +from statek.executors.chat_log_item import SubTaskLogItem +from statek.executors.utils import _setup_execution_context +from statek.llm_api import select_request_tools +from statek.system import find_tools, get_pending_notifications as system_get_pending_notifications +from statek.task import SubTaskHandler +from statek.utils import _statek_ctx_scope + + +def test_pending_notifications_empty_and_repeatable(job_factory): + """Reading an empty pending queue never changes the job.""" + job = job_factory() + with _statek_ctx_scope({"job": job}): + assert not get_pending_notifications() + assert not get_pending_notifications() + assert job.chat_log == [] + + +def test_pending_notifications_are_ordered_detached_and_non_consuming(job_factory): + """Returned collection is independent while its entries retain identity.""" + job = job_factory() + first = SubTaskLogItem(console_pos=0, handler=SubTaskHandler(job=job, id="first")) + second = SubTaskLogItem(console_pos=0, handler=SubTaskHandler(job=job, id="second")) + job._pending_notifications().extend([first, second]) + initial_status = job.status + + with _statek_ctx_scope({"job": job}): + snapshot = get_pending_notifications() + assert snapshot == [first, second] + snapshot.clear() + assert get_pending_notifications() == [first, second] + + assert job.status == initial_status + assert job.chat_log == [] + assert job._pending_notifications() == [first, second] + job._process_pending_notifications() + with _statek_ctx_scope({"job": job}): + assert not get_pending_notifications() + + +def test_pending_notifications_initializes_old_job_queue(job_factory): + """Previously stored jobs without an initialized queue can be inspected.""" + job = job_factory() + job._Job__pending_chat_log = None + with _statek_ctx_scope({"job": job}): + assert not get_pending_notifications() + + +def test_pending_notifications_requires_current_job(): + """Missing execution context is reported rather than treated as empty.""" + with _statek_ctx_scope({}): + with pytest.raises(RuntimeError, match="current job"): + get_pending_notifications() + + +def test_pending_notifications_system_discovery_and_scope(): + """Registered tool is selectable in system scopes but not application-only.""" + assert get_pending_notifications is system_get_pending_notifications + assert get_pending_notifications in find_tools("SYSTEM") + assert get_pending_notifications in select_request_tools( + metadata={"LLM_TOOLS_SCOPE": "SYSTEM"}, available_tools=[] + ) + assert get_pending_notifications in select_request_tools( + metadata={"LLM_TOOLS_SCOPE": "+get_pending_notifications"}, available_tools=[] + ) + assert get_pending_notifications not in select_request_tools( + metadata={"LLM_TOOLS_SCOPE": "APPLICATION"}, available_tools=[] + ) + + +def test_pending_notifications_is_injected_into_python_execution(job_factory): + """A job's Python environment exposes the registered system tool.""" + job = job_factory() + global_context = {} + with _setup_execution_context(job, global_context, {}): + assert global_context["get_pending_notifications"]() == [] From b9c3d2ccf88d28b70a53f2e7f8d0039a6bc7a015 Mon Sep 17 00:00:00 2001 From: Kiki McArron Date: Tue, 29 Sep 2026 17:05:23 +0200 Subject: [PATCH 2/4] Implement --- statek/executors/chat_log_item.py | 12 ++++ statek/executors/job.py | 45 +++++++++++-- statek/executors/utils.py | 3 +- tests/test_job.py | 105 +++++++++++++++++++++++++++++- tests/test_notification_tools.py | 16 ++++- tests/test_run_job_step.py | 65 ++++++++++++++++-- 6 files changed, 232 insertions(+), 14 deletions(-) diff --git a/statek/executors/chat_log_item.py b/statek/executors/chat_log_item.py index 7f4fc36..b313b16 100644 --- a/statek/executors/chat_log_item.py +++ b/statek/executors/chat_log_item.py @@ -124,6 +124,18 @@ class SubTaskLogItem(ChatLogItem): handler: "SubTaskHandler" +@db0.memo(no_default_tags=True) +@dataclass(kw_only=True) +class NotificationLogItem(ChatLogItem): + """An arbitrary plain-text message delivered between jobs.""" + + message: str + + def __llm_repr__(self) -> str: + """Show the message without recursively formatting its saved tool result.""" + return f"NotificationLogItem(message={self.message!r})" + + @db0.memo(no_default_tags=True) @dataclass class UserLogItem: diff --git a/statek/executors/job.py b/statek/executors/job.py index 2d4b2c4..7bb4217 100644 --- a/statek/executors/job.py +++ b/statek/executors/job.py @@ -42,6 +42,7 @@ from statek.executors.chat_log_item import ( ChatLogItem, LLM_LogItem, + NotificationLogItem, PostProcessedItem, ReminderLogItem, SubTaskLogItem, @@ -64,6 +65,7 @@ build_warmup_code, extract_dialog, get_current_job, + format_default_llm_repr, parse_tool_log, parse_warmup_block, perm_ctx_get, @@ -693,8 +695,8 @@ def add_locals(self, **kwargs) -> None: """Inject additional values into this job's Python local state.""" self.py_env.update_locals(**kwargs) - def _pending_notifications(self) -> List[SubTaskLogItem]: - """Return pending subtask notifications, initializing old jobs lazily.""" + def _pending_notifications(self) -> List[Union[SubTaskLogItem, NotificationLogItem]]: + """Return pending chat-log notifications, initializing old jobs lazily.""" if not hasattr(self, "_Job__pending_chat_log") or self.__pending_chat_log is None: self.__pending_chat_log = [] return self.__pending_chat_log @@ -702,7 +704,7 @@ def _pending_notifications(self) -> List[SubTaskLogItem]: def find_sub_task_handler(self, id: Optional[Any] = None) -> Optional["SubTaskHandler"]: # pylint: disable=redefined-builtin """Find a pending or logged subtask handler by id, or the most recent one.""" for item in reversed(self._pending_notifications()): - if id is None or item.handler.id == id: + if isinstance(item, SubTaskLogItem) and (id is None or item.handler.id == id): return item.handler for item in reversed(self.chat_log): if isinstance(item, SubTaskLogItem) and (id is None or item.handler.id == id): @@ -730,10 +732,15 @@ def _chat_log_finalized_for_notifications(self) -> bool: return True def _process_pending_notifications(self) -> bool: - """Move pending subtask notifications to chat_log at safe boundaries.""" + """Move finalized pending notifications into the ordered chat history.""" pending = self._pending_notifications() if not pending or not self._chat_log_finalized_for_notifications(): return False + console_pos = len(self.py_env.console) if self.py_env.console else 0 + for item in pending: + item.console_pos = console_pos + if isinstance(item, NotificationLogItem) and item.tool_log is None: + item.tool_log = format_default_llm_repr([item]) self.chat_log.extend(pending) self.__pending_chat_log = [] return True @@ -1062,6 +1069,16 @@ def _subtask_tool_call(idx: int, handler: "SubTaskHandler") -> CallSpecWrapper: kwargs={"code": Job._subtask_lookup_code(handler)}, ) + @staticmethod + def _notification_tool_call(idx: int) -> CallSpecWrapper: + """Return the synthetic python_cli call for one delivered text message.""" + return CallSpecWrapper( + id=f"STATEK-NOTIFICATION-{idx:03d}", + func_name="python_cli", + args=None, + kwargs={"code": "print(get_pending_notifications())"}, + ) + def get_next_prompt(self) -> str: """ Generate the next prompt to be included in the LLM chat. @@ -1237,6 +1254,7 @@ def _build_chat_history( PostProcessedItem, ReminderLogItem, SubTaskLogItem, + NotificationLogItem, )): next_pos = items[j].console_pos break @@ -1319,6 +1337,25 @@ def _yield_pushes(lo: int, hi: int) -> Iterable[ChatHistoryItem]: ) continue + if isinstance(item, NotificationLogItem): + call = self._notification_tool_call(idx) + yield ChatHistoryItem( + role=ChatRole.ASSISTANT, + content=None, + content_src=ContentSource.SYSTEM, + tool_calls=[call], + ) + result = item.tool_log if item.tool_log is not None else format_default_llm_repr([item]) + if isinstance(result, ToolError): + result = result.err_message + yield ChatHistoryItem( + role=ChatRole.TOOL, + content=result, + content_src=ContentSource.CONSOLE, + tool_calls=[call], + ) + continue + # Determine the source content of the assistant turn. if isinstance(item, WarmupLogItem): block_num = item.warmup_block_num diff --git a/statek/executors/utils.py b/statek/executors/utils.py index 865ed7f..309f53f 100644 --- a/statek/executors/utils.py +++ b/statek/executors/utils.py @@ -938,7 +938,7 @@ def _log_pending_console(job: Job): def _process_pending_notification_follow_up(job: Job, harness) -> bool: - """Process pending subtask notifications before a job is finalized.""" + """Process pending notifications before a job is finalized.""" if not job._process_pending_notifications(): # pylint: disable=protected-access return False job.py_env.exit_status = None @@ -1211,6 +1211,7 @@ def _cli_error_append(cli_idx, text): # Step 12: Get next request parameters — log pending console batch first _log_pending_console(job) + job._process_pending_notifications() # pylint: disable=protected-access request_difficulty = job.get_current_difficulty() request = job.get_next_request(difficulty=request_difficulty) request["metadata"] = dict(request["metadata"] or {}) diff --git a/tests/test_job.py b/tests/test_job.py index 5ab46dc..c056935 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -33,6 +33,7 @@ from statek.agents.dialog_agent import RecurringReminder from statek.executors.chat_log_item import ( LLM_LogItem, + NotificationLogItem, PostProcessedItem, ReminderLogItem, SubTaskLogItem, @@ -44,7 +45,7 @@ from statek.settings import ChatStyle, LLM_API_Settings from statek.locale import StatekLocale, StatekLangCode, StatekCountryCode from statek.prompt_config import make_system_prompt, parse_system_prompt -from statek.utils import CodeBlock, CallSpec, _statek_ctx_scope +from statek.utils import CodeBlock, CallSpec, _statek_ctx_scope, format_default_llm_repr from statek.system import find_sub_task_handler from statek.task import SubTaskHandler, TaskError @@ -3056,6 +3057,108 @@ def test_push_notification_active_buffers_until_last_item_finalized(self, job_fa assert job._process_pending_notifications() is False # pylint: disable=protected-access +# pylint: disable=protected-access +class TestTextNotificationHistory: + """A plain-text notification is a durable, synthetic tool-result event.""" + + def test_notification_is_persisted_and_formatted_without_recursive_tool_log(self, job_factory): + """The message is required and the saved result does not expand itself.""" + job = job_factory() + item = NotificationLogItem(console_pos=0, message="Żółw\n