Files
openworker/tests/test_context_block_placement.py
Devika Verma c1e8b8cdf3 Make the time a tool instead of a line in every turn (OPE-192, final fix)
The per-turn `<system-context>` block opened with `Now: YYYY-MM-DD HH:MM` (2c9fb74,
2026-08-20) so that absolute scheduling had a clock. The block is glued onto the last
user message — the task prompt in a tool loop — and a provider's prompt cache is
reusable only up to the first byte that differs, so every minute crossing rewrote
message one and re-processed the whole conversation. 9d6a716 worked around it with a
trailing-message shape, an Anthropic-only relocation of that message, and a
`live_clock` switch that headless runs set off. This commit removes the cause instead
and deletes the workarounds; the shape is the same on every provider again.

- The block carries no clock, on any provider. What is left (folders, skill menu,
  mode notices, memory-off notice, disable countermand) changes only when the user
  changes something, so the outbound history is byte-identical turn to turn.
- `current_time` tool (coworker/clock.py), registered for every persona: local time
  with offset and zone, the same instant in UTC, weekday. The environment block's
  "Today's date" now says it is a session-start snapshot and points at the tool.
- `sleep_for(seconds)` is back beside `sleep_until` (a relative wait needs no clock
  arithmetic; the four lead personas' manifests still told the model to call
  sleep_for, which 2c9fb74 had removed). `sleep_until`'s description points at
  current_time for the date/timezone. Timer wake messages carry their fire time.
- Deleted: `live_clock` config + COWORKER_LIVE_CLOCK, the trailing-message shape in
  `_outbound_messages`, `_relocate_ephemeral_note` in the Anthropic provider. The
  cache-diagnostics instrument (off by default) stays.

Validation runs (one long tool-heavy task, one attempt each on Fable 5.1 and Kimi K3,
compared with the 9d6a716 runs of the same task) are recorded on OPE-192.

Tests: tests/test_clock_tool.py (new), tests/test_context_block_placement.py and
tests/test_cache_breakpoint_placement.py rewritten for the single shape,
tests/test_live_clock.py removed; self-wake, attachments, message-source and
multiroot tests updated. Full suite unchanged against the Windows baseline.
2026-09-17 18:25:47 +05:30

128 lines
5.2 KiB
Python

"""OPE-192: the per-turn `<system-context>` block (folders, skill menu, mode notices) rides
on the last user message — one shape for every provider — and must hold nothing that moves
on its own. In a tool loop that message is the task prompt, message one; a provider's
prompt cache is reusable only up to the first byte that differs, so a self-changing value
there (the live clock this block used to carry) re-processed the whole conversation on
every turn. No engine loop here: `_outbound_messages` alone."""
from __future__ import annotations
from coworker.engine import TurnEngine
from coworker.permissions import PermissionEngine
from coworker.tools import ToolRegistry
def _engine(tmp_path, messages, ctx):
return TurnEngine(
provider=object(),
registry=ToolRegistry(),
permissions=PermissionEngine(workspace_root=tmp_path),
model="x",
messages=messages,
context_provider=lambda: ctx[0],
)
def _tool_loop(turns=3):
msgs = [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "Reconstruct the scene file from this image."},
]
for i in range(turns):
msgs.append(
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": f"c{i}", "function": {"name": "run_shell", "arguments": '{"command": "ls"}'}}],
}
)
msgs.append({"role": "tool", "tool_call_id": f"c{i}", "content": f"output {i}"})
return msgs
FOLDERS = "Available directories: /app"
def test_tool_loop_block_rides_on_the_task_prompt_and_nowhere_else(tmp_path):
eng = _engine(tmp_path, _tool_loop(), [FOLDERS])
out = eng._outbound_messages()
assert len(out) == len(eng.messages) # no extra message
assert out[1]["content"].startswith("Reconstruct the scene file from this image.\n\n<system-context>\n(automatic per-turn context")
assert out[1]["content"].endswith(f"\n{FOLDERS}\n</system-context>")
# Everything the model said and ran is untouched; the final message is the tool result.
assert out[-1]["role"] == "tool" and out[-1]["content"] == "output 2"
assert "<system-context>" not in str(out[2:])
# Ephemeral: the canonical history never carries the block.
assert eng.messages[1]["content"] == "Reconstruct the scene file from this image."
def test_tool_loop_request_is_byte_identical_turn_to_turn(tmp_path):
"""The property that keeps the prompt cache warm: with nothing self-changing in the
block, two consecutive requests over the same history are equal, byte for byte."""
eng = _engine(tmp_path, _tool_loop(), [FOLDERS])
assert eng._outbound_messages() == eng._outbound_messages()
def test_tool_loop_next_turn_keeps_the_previous_request_as_a_prefix(tmp_path):
eng = _engine(tmp_path, _tool_loop(turns=2), [FOLDERS])
before = eng._outbound_messages()
eng.messages.append(
{"role": "assistant", "content": "", "tool_calls": [{"id": "c9", "function": {"name": "run_shell", "arguments": "{}"}}]}
)
eng.messages.append({"role": "tool", "tool_call_id": "c9", "content": "output 9"})
after = eng._outbound_messages()
assert after[: len(before)] == before # the whole previous request is a prefix
assert after[-1]["content"] == "output 9"
def test_a_block_change_touches_only_the_message_it_rides_on(tmp_path):
"""When the user grants a folder or flips a mode the block changes — once, and only
the message carrying it differs. That is the cost of a user action, not of time."""
ctx = [FOLDERS]
eng = _engine(tmp_path, _tool_loop(), ctx)
first = eng._outbound_messages()
ctx[0] = FOLDERS + "\nAvailable directories: /data"
second = eng._outbound_messages()
assert first[0] == second[0] and first[2:] == second[2:]
assert first[1] != second[1]
def test_chat_block_rides_on_the_newest_user_message(tmp_path):
msgs = [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "draft an email"},
{"role": "assistant", "content": "draft"},
{"role": "user", "content": "make it shorter"},
]
eng = _engine(tmp_path, msgs, [FOLDERS])
out = eng._outbound_messages()
assert len(out) == len(msgs)
assert out[-1]["content"].startswith("make it shorter\n\n<system-context>")
assert out[1]["content"] == "draft an email" # earlier user turns untouched
assert eng.messages[-1]["content"] == "make it shorter"
def test_content_parts_get_the_block_as_a_trailing_text_part(tmp_path):
msgs = [
{"role": "user", "content": [{"type": "text", "text": "look"}, {"type": "text", "text": "closely"}]},
]
eng = _engine(tmp_path, msgs, [FOLDERS])
out = eng._outbound_messages()
assert len(out) == 1
parts = out[0]["content"]
assert parts[:2] == msgs[0]["content"]
assert parts[2]["type"] == "text" and parts[2]["text"].startswith("\n\n<system-context>")
def test_no_context_means_no_block_anywhere(tmp_path):
eng = TurnEngine(
provider=object(),
registry=ToolRegistry(),
permissions=PermissionEngine(workspace_root=tmp_path),
model="x",
messages=_tool_loop(),
context_provider=lambda: "",
)
out = eng._outbound_messages()
assert out == eng.messages and "<system-context>" not in str(out)