Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/seahaven/cli/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ def add_parser(subcommands: argparse._SubParsersAction[argparse.ArgumentParser])
parser.add_argument(
"--include-control-tools",
action="store_true",
help="make the control tool callable in each session; without the flag its name is "
"an unknown tool, and it is never listed either way",
help="make the control tools, such as controller_advance_clock, callable in each "
"session; without the flag their names are unknown tools, and they are never listed "
"either way",
)
parser.add_argument(
"--no-console",
Expand Down
38 changes: 26 additions & 12 deletions src/seahaven/clock.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""An instance's clock, and the SQLite date and time functions that read it.

The clock has a mode, chosen when the instance is made and kept for its life:
`fixed` reads the start instant for ever, `tick` adds a second per call,
`running` adds the real time elapsed since the clock was made, and `wall` reads
the host's clock. Every reading is computed when it is asked for, so one `Clock`
object shared by reference is the single source of time for every door of an
instance.
`fixed` reads the start instant, `tick` adds a second per call, `running` adds
the real time elapsed since the clock was made, and `wall` reads the host's
clock. Every mode but `wall` also adds however far the instance has advanced it
(`Instance.advance_clock`). Every reading is computed when it is asked for, so
one `Clock` object shared by reference is the single source of time for every
door of an instance.

Nothing inside a world may read the wall clock directly, and SQL is the door
that is easy to forget: `CURRENT_TIMESTAMP` and friends are parsed as
Expand Down Expand Up @@ -46,6 +47,7 @@
CLOCK_MODES: tuple[ClockMode, ...] = ("fixed", "tick", "running", "wall")
DEFAULT_CLOCK_MODE: ClockMode = "running"
TICK = timedelta(seconds=1)
_MILLISECOND = timedelta(milliseconds=1)

# The `id` the per-statement trace is registered under. APSW keeps one trace per
# id on a connection, so another trace added under a different id leaves this one
Expand Down Expand Up @@ -146,16 +148,20 @@ class Clock:

The framework makes one when it creates an instance, in the instance's mode,
and every connection and context of the instance reads that one object.
`Clock(datetime)` built directly is a `fixed` clock at that instant, which is
also the way to render a datetime as canonical text. Building a `Clock` does
not choose or change any instance's mode: that is the `clock_mode` reset
option, and `World(default_clock_mode=...)` for the world's default.
World code reads it and cannot move it; a harness moves an instance's clock
forward with `inst.advance_clock(...)`. `Clock(datetime)` built directly is a
`fixed` clock at that instant, which is also the way to render a datetime as
canonical text. Building a `Clock` does not choose or change any instance's
mode: that is the `clock_mode` reset option, and
`World(default_clock_mode=...)` for the world's default.
"""

def __init__(self, now: datetime, mode: ClockMode = "fixed") -> None:
self._start = _truncated(now)
self._mode = check_clock_mode(mode)
self._calls = 0
# How far the instance has moved the clock forward, in whole milliseconds.
self._advanced = timedelta()
self._origin_ns = _monotonic_ns()

@classmethod
Expand All @@ -180,12 +186,12 @@ def now(self) -> datetime:
"""The current reading. Reading the clock never moves it."""
match self._mode:
case "fixed":
return self._start
return self._start + self._advanced
case "tick":
return self._start + self._calls * TICK
return self._start + self._advanced + self._calls * TICK
case "running":
elapsed_ms = (_monotonic_ns() - self._origin_ns) // 1_000_000
return self._start + timedelta(milliseconds=elapsed_ms)
return self._start + self._advanced + timedelta(milliseconds=elapsed_ms)
case "wall":
return _truncated(_wall_now())

Expand All @@ -198,6 +204,14 @@ def _call_started(self) -> None:
"""Count a call. The instance calls this as a call takes its ordinal."""
self._calls += 1

def _advance(self, by: timedelta) -> None:
"""Move the reading forward by `by`, cut to whole milliseconds.

The instance calls this from `advance_clock`, which has already refused
a `wall` clock and a negative `by`.
"""
self._advanced += by // _MILLISECOND * _MILLISECOND

def __repr__(self) -> str:
return f"Clock({self.iso()}, {self._mode})"

Expand Down
101 changes: 79 additions & 22 deletions src/seahaven/control.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
"""The framework's own tool: what an eval asks an instance, not what an agent does.
"""The framework's own tools: what an eval does to an instance, not what an agent does.

`controller_run_sql` is registered on every world, is never in its tool list, and
Each control tool is registered on every world, is never in its tool list, and
is dispatched straight from `Instance.call` -- around the middleware chain, the
world's error handler, the per-call transaction and the concurrency gate. An eval
asking what an instance holds wants the real answer and the real message, and it
must not have to wait behind the agent's queue to get one.
driving an instance wants the real answer and the real message, and it must not
have to wait behind the agent's queue to get one.

Registration is not permission. Only an instance made with
`world.instance(..., control_tools=True)` can call one; on every other instance
the name answers `UnknownTool`, in the words any name the world does not have
earns. The registration is what lets `World._add` keep refusing a world that
tries to register the name itself.

It is deprecated: `inst.state()` is what an eval reads now, and every call
through here warns. It is a thin wrapper -- a read-only handle on the instance is
what it reads through -- and it writes no SQL of its own. It covers a composite
instance's every node without a line here that knows it, because the handle
carries each added node attached under its own schema.
`controller_advance_clock` is `Instance.advance_clock` for a harness at the other
end of the OpenEnv wire, where nothing else reaches one session's instance: a
`reset` only sets where the clock starts.

`controller_run_sql` is deprecated: `inst.state()` is what an eval reads now, and
every call of it warns. It is a thin wrapper -- a read-only handle on the
instance is what it reads through -- and it writes no SQL of its own. It covers a
composite instance's every node without a line here that knows it, because the
handle carries each added node attached under its own schema.

A control call takes the instance lock like any call, so it never interleaves
with a step on the same instance, and it asks the instance for what it needs with
that lock already held, which is why the lock is an `RLock`.

The handle it reads through is the instance's control handle and not the
`inspect()` one an eval holds. Same file, same read-only opener, different
connection: `sandbox.run_statement` borrows connection-level state -- the
authorizer, the value limit -- for the length of a statement, and `inspect()`
reads do not take the instance lock, so a control read on that connection would
be a second thread changing it under a cursor that is stepping. See
`Instance._control_db`.
The handle `controller_run_sql` reads through is the instance's control handle
and not the `inspect()` one an eval holds. Same file, same read-only opener,
different connection: `sandbox.run_statement` borrows connection-level state --
the authorizer, the value limit -- for the length of a statement, and
`inspect()` reads do not take the instance lock, so a control read on that
connection would be a second thread changing it under a cursor that is
stepping. See `Instance._control_db`.
"""

import dataclasses
Expand All @@ -37,28 +41,40 @@
import os
import warnings
from collections.abc import Callable
from datetime import timedelta
from types import FunctionType
from typing import Any
from typing import Annotated, Any

import apsw
from pydantic import Field

from seahaven import sandbox
from seahaven.call import serialise
from seahaven.ctx import Ctx
from seahaven.db import SqlValue
from seahaven.errors import DbError, WorldBug
from seahaven.errors import DbError, ToolError, WorldBug
from seahaven.helpers.run_sql import showing_sqlite_text, to_result
from seahaven.instances import Instance
from seahaven.sandbox import Authorizer
from seahaven.tool import Tool

__all__ = ["DEPRECATED", "TOOLS", "controller_run_sql", "dispatch"]
__all__ = [
"CLOCK_NOT_ADVANCED",
"DEPRECATED",
"TOOLS",
"controller_advance_clock",
"controller_run_sql",
"dispatch",
]

# The control tools whose calls warn. `functional_spec.md` §11 keeps
# `controller_run_sql` working and schedules no removal for it; `state()` is what
# replaced it.
DEPRECATED = frozenset({"controller_run_sql"})

# The code of every refusal `controller_advance_clock` answers.
CLOCK_NOT_ADVANCED = "clock_not_advanced"

# Every frame inside the framework, so that `warnings.warn` walks past all of
# them and reports the caller's own line. A `stacklevel` cannot do this: a
# control tool is reached through `Instance.call` in process, through the OpenEnv
Expand All @@ -67,6 +83,44 @@
_FRAMEWORK = (os.path.dirname(os.path.abspath(__file__)) + os.sep,)


def controller_advance_clock(
instance: Instance,
ctx: Ctx,
seconds: Annotated[float, Field(ge=0, allow_inf_nan=False)] | None = None,
to: str | None = None,
) -> dict[str, str]:
"""Move the instance's clock forward, by `seconds` or to the instant `to`.

Give exactly one. `to` is a timestamp in any form `reset` takes for `now`.
Answers the clock's new reading as `now`, and its mode as `clock_mode`. A
refusal, such as a `to` earlier than the clock's reading, a `wall` clock, or
both arguments or neither, is a tool error coded `clock_not_advanced`.
"""
# Refused here rather than by `advance_clock`, whose refusal names its own
# `by=`: the caller sent `seconds`.
if (seconds is None) == (to is None):
raise ToolError(
CLOCK_NOT_ADVANCED, "controller_advance_clock takes exactly one of seconds and to"
)
try:
reading = instance.advance_clock(None if seconds is None else _duration(seconds), to=to)
except WorldBug as error:
# A `WorldBug` reaches an OpenEnv harness as "internal error" and nothing
# more (`SeahavenEnv._call`), and every refusal here is the harness's to
# read and act on.
raise ToolError(CLOCK_NOT_ADVANCED, str(error)) from error
return {"now": reading, "clock_mode": instance.clock.mode}


def _duration(seconds: float) -> timedelta:
try:
return timedelta(seconds=seconds)
except OverflowError:
# More than a `timedelta` holds is further than any clock can move, and
# `advance_clock` refuses that in its own words.
return timedelta.max


def controller_run_sql(
instance: Instance, ctx: Ctx, sql: str, params: list[SqlValue] | None = None
) -> dict[str, Any]:
Expand Down Expand Up @@ -219,6 +273,9 @@ def _control_tool(fn: FunctionType) -> Tool:
return dataclasses.replace(tool, fn=fn, control=True)


# Registered on every `World` at construction. Built once, at import: it carries
# no world and no instance, so one tool serves every world in the process.
TOOLS: tuple[Tool, ...] = (_control_tool(controller_run_sql),)
# Registered on every `World` at construction. Built once, at import: they carry
# no world and no instance, so one of each serves every world in the process.
TOOLS: tuple[Tool, ...] = (
_control_tool(controller_run_sql),
_control_tool(controller_advance_clock),
)
2 changes: 1 addition & 1 deletion src/seahaven/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ def open_inspection(
"""Open a second, read-only view of an instance for looking at its state.

`stream` names which read-only door this is -- `INSPECTION_STREAM` for the
handle a caller reads state through, `CONTROL_STREAM` for the control tool's
handle a caller reads state through, `CONTROL_STREAM` for `controller_run_sql`'s
own -- so that two doors onto one instance do not hand out the same random
values. See `ids.register_random_functions`. One door is one stream whatever
it is attached to: a composite instance's read-only handles each draw from a
Expand Down
6 changes: 3 additions & 3 deletions src/seahaven/docs/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,8 @@ All of it happens at import, with a message naming the tool and the parameter. A
when it:

- duplicates another tool's name;
- is named `reset`, `step`, `state` or `close` (OpenEnv reserves those), or the control tool that
[reference/cli.md](reference/cli.md) names;
- is named `reset`, `step`, `state` or `close` (OpenEnv reserves those), or has the name of a
control tool ([reference/cli.md](reference/cli.md) names them);
- is an `async def`, a generator, or an async generator;
- has no first parameter, or one that is not positional and either annotated `seahaven.Ctx` or left
unannotated;
Expand Down Expand Up @@ -509,7 +509,7 @@ Common uses:
together.
- **What it wraps.** Middleware runs for every tool call, including the helpers' and an
extension's. It does not run for `UnknownTool`, a tool listing, startup hooks or the control
tool.
tools.

## Instance startup

Expand Down
37 changes: 35 additions & 2 deletions src/seahaven/docs/clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Every instance has a clock of its own, and everything in the world reads it: world code through
`ctx.clock`, and SQL through SQLite's own date and time functions. This page explains how the clock
starts, the four modes that say how it moves, and what they mean for the timestamps a world writes.
starts, the four modes that say how it moves, how a harness moves it forward, and what that means
for the timestamps a world writes.

## Modes

Expand All @@ -12,11 +13,14 @@ how the clock moves from there:

| Mode | What the clock reads | Replays |
|---|---|---|
| `fixed` | The start instant, for the instance's whole life | Yes |
| `fixed` | The start instant | Yes |
| `tick` | The start instant plus one second per tool call dispatched so far | Yes |
| `running` | The start instant plus the real time elapsed since the instance was created | No |
| `wall` | The host's current UTC time. The start instant is not used | No |

Under every mode but `wall`, a harness can also move the clock forward. See
[Moving the clock forward](#moving-the-clock-forward).

## Choosing a mode

`running` is the default. A world sets its own default with `World(default_clock_mode=...)`. One
Expand Down Expand Up @@ -98,6 +102,35 @@ with world.instance("small_startup", now="2026-06-04T09:00:00.000Z", clock_mode=
A `now=` earlier than the fixture's `now` is refused. The fixture's rows are dated by its clock, so
an earlier start would give the instance rows created in its future.

## Moving the clock forward

A harness can move an instance's clock forward between calls, so that a retry window or a due date
arrives without waiting for it. `inst.advance_clock(by)` moves the clock by a `timedelta`, and
`inst.advance_clock(to=...)` moves it to an instant, in either form `now=` takes. Both answer the
new reading:

```python
from datetime import timedelta

import projecttracker

world = projecttracker.world

with world.instance("small_startup", clock_mode="tick") as inst:
assert inst.advance_clock(timedelta(days=2)) == "2026-06-03T09:00:00.000Z"
inst.call("list_teams")
assert inst.clock.iso() == "2026-06-03T09:00:01.000Z"
assert inst.advance_clock(to="2026-06-04T09:00:00.000Z") == "2026-06-04T09:00:00.000Z"
```

The clock keeps its mode and goes on from the new reading: `tick` still adds a second per call, and
`running` still runs. An advance is not a call, so it does not tick and is not in the call log.
The clock only moves forward, and a `wall` clock cannot be moved. World code reads the clock and
cannot move it.

Over a server, a harness calls the `controller_advance_clock` control tool instead. See
[serving_and_openenv.md](serving_and_openenv.md#moving-the-clock).

## Timestamps are not a complete order

Design around one consequence: **a timestamp is not a complete order.** Under `fixed`, every row
Expand Down
4 changes: 2 additions & 2 deletions src/seahaven/docs/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ observes**, so a host that cares orders its `add_world` calls to match the clien
Three rules fall out of that:

- After filtering and prefixing, every name must be unique across the whole tree, and must not be
one of the reserved names (`reset`, `step`, `state`, `close`, and the control tool). A
collision is a seal error, never resolved silently.
one of the reserved names (`reset`, `step`, `state`, `close`, and the names of the control
tools). A collision is a seal error, never resolved silently.
- A prefixed name must still be a valid tool name (`^[A-Za-z0-9_-]{1,128}$`), so a `tool_prefix` of
`"payments."` is refused rather than published.
- Control tools are never contributed. Every world registers them, but a call by that name resolves
Expand Down
5 changes: 2 additions & 3 deletions src/seahaven/docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,5 @@ turns a log into the net difference an episode made.
## What Seahaven does not do

Seahaven does not put a time limit on a tool call, filter or project tools per instance, mock a
tool's response, move a clock forward, or generate data for you. It contains agent-written SQL and
nothing else. None of these is planned. If your eval needs one, build it into the harness around the
world.
tool's response, or generate data for you. It contains agent-written SQL and nothing else. None of
these is planned. If your eval needs one, build it into the harness around the world.
6 changes: 3 additions & 3 deletions src/seahaven/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ errors (`WorldBug`, `ToolError`, `ArgumentError`, `DbError`, `UnknownTool`).
- **Register itself.** The world registers what it wants, by name, in its own `__init__`. An
extension that registered itself on import would make a world's tool surface depend on its import
order.
- **Set `control`.** That flag is Seahaven's own, for its control tool, which is listed nowhere and
is callable only on an instance made with `control_tools=True`. `Tool.from_function` cannot set
it.
- **Set `control`.** That flag is Seahaven's own, for its control tools, which are listed nowhere
and are callable only on an instance made with `control_tools=True`. `Tool.from_function` cannot
set it.

## The worked example: XML-RPC

Expand Down
2 changes: 1 addition & 1 deletion src/seahaven/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Read these in order the first time. Each page assumes the ones above it.
| [concepts.md](concepts.md) | The nine words the rest of the docs use: world, tool, schema, fixture, instance, context, clock, reproducibility, change log |
| [authoring.md](authoring.md) | Writing a world: tools, arguments, transactions, errors, middleware, startup hooks |
| [db_schema_and_fixtures.md](db_schema_and_fixtures.md) | The database, the schema rules, and how fixtures are built and kept |
| [clock.md](clock.md) | The instance's clock: the four modes, choosing one, and what they mean for timestamps |
| [clock.md](clock.md) | The instance's clock: the four modes, choosing one, moving it forward, and what they mean for timestamps |
| [testing.md](testing.md) | The pytest plugin, and what is worth testing in a world |
| [state.md](state.md) | The document an eval grades on: the change log, the formats, and the fold |
| [serving_and_openenv.md](serving_and_openenv.md) | `seahaven serve`, driving a world over the network, publishing it, and `seahaven mcp` |
Expand Down
Loading
Loading