mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 13:27:11 +00:00
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, which2c9fb74had 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.
128 lines
5.2 KiB
Python
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)
|