diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be85625..1239b24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,58 @@ All notable sagent changes are documented here. This project follows ## Unreleased +### Added + +- A `Memory` tool saves and forgets project memories and keeps `MEMORY.md` + in step with them, newest first. It is in the default toolset wherever the + model is shown the memory index: an `--ephemeral` session, or a recipe + without the `memory` section, leaves it out and refuses `--tools Memory`. +- `--system-prompt` replaces the recipe's base prompt, and + `--append-system-prompt` adds instructions after it. +- `Agent(append_system=..., prompt_context=...)`. A `prompt_context` + (`prompt.SagentContext` for the CLI's) renders the project instructions + and the environment. `Agent.reminders` holds standing context a host keeps + in force, and tools may implement `types.tools.Reminding`. +- `SystemMessage`, a history entry for what the harness tells the model (the + environment, tool and host reminders, a tool-gate change). It is no one's + turn, is never merged into a human's or a peer's message, and persists as a + `system_message` record that session readers report as a system message. On the + wire it is a `` in the user turn it follows, as before. +- `UserMessage.offered_tools`, set on the wire turn a gate-changing system + message folds into, and `ModelRequest.allowed_tools`, read from the latest + one. `UserMessage.cache_point` now persists, and survives a merge. + +### Changed + +- The system prompt is a tuple of blocks (`ModelRequest.system`), built once + per context and rebuilt on `/clear`, compaction and resume, and when + `replace_tool` rewords a tool's prompt. Anthropic marks + each block as a cache point, so a child with its parent's tools reads the + parent's cached prefix. What changes -- the environment, the agents one can + message, the spawn budget, the tool gate -- reaches the model as an + appended `SystemMessage`, retold whenever the context stops holding it. +- `Agent.tool_gate` masks tools instead of removing their schemas: Anthropic + withdraws them with the inline-tools beta, OpenAI receives + `tool_choice: allowed_tools`, and a call outside the gate its request + offered is refused on every provider. +- The root agent gets `BackgroundTask` with `AgentSpawn`, as a child does. +- A merge of user-side messages from different senders labels each part with + its own sender instead of giving the whole an `AgentSendMessage` source. + ### Removed - `sagent.lib.worker_count` is no longer part of the package. It moved, with the pre-commit gates that use it, to `tools/gate/` at the repository root, which the wheel does not ship. +- `--system`, which added instructions; it now fails with a pointer to + `--append-system-prompt` and `--system-prompt`. +- `AgentSpawn`'s `hot` argument and `Agent(frozen_system=...)`: every child + now shares its parent's system blocks. A persistent child a hot spawn saved + is not resumed: its saved prompt is its parent's whole rendered prompt. +- `Agent(system=)` and `SystemPromptArg`; `system` is a string. +- `prompt.build_system` and `prompt.build_system_dict`, replaced by + `prompt.static_prompt`, `prompt.project_context`, + `prompt.render_environment` and `prompt.SagentContext`. ## 0.1.20 - 2026-10-21 diff --git a/docs/cli.md b/docs/cli.md index 5910974a..8c764381 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,7 +44,8 @@ sagent --provider SelfHosted --model /opt/models/qwen3.6-27b+bfloat16+cuda | `--auth METHOD` | Calls `Provider.from_()`; an explicit API-key provider defaults to `env`, while subscription providers default to `credentials`. | | `--account NAME` | Optional named credential slot for providers that support one. | | `--model ID` | Catalog model ID. Bare IDs select the largest context window. Models with smaller profiles accept `+200k` or `+272k`; an explicit `+1m` remains valid when the bare profile is already at least one million tokens. SelfHosted IDs may include `+cuda`, `+bfloat16`, or `+compile`. | -| `--system TEXT` | Extra system prompt instructions appended to Sagent's default prompt. | +| `--system-prompt TEXT` | System prompt replacing Sagent's default prompt. | +| `--append-system-prompt TEXT` | Instructions added after the system prompt. | | `--effort LEVEL` | Reasoning effort: `none`, `min`, `low`, `medium`, `high`, `xhigh`, or `max`. Each model accepts a subset (GPT-5.6 takes all seven; GPT-6 Astra has no `none`), and an unsupported value raises. | | `--max-response-tokens N` | Limit response tokens for each model call. | | `--log-level LEVEL` | Enable stderr diagnostics. Same values as Python logging levels. | @@ -88,9 +89,12 @@ sagent --tools Read Bash BackgroundTask `--tools` accepts exact class names exported from `sagent.tools`. The default terminal tool set is: ```text -AgentSpawn AgentSend AgentSelf Bash Read Write Edit Grep Glob List WebSearch WebFetch PaperSearch PaperDetails PaperAuthor PaperFetch PlayAudio Skill +AgentSpawn AgentSend AgentSelf Bash Read Write Edit Grep Glob List WebSearch WebFetch PaperSearch PaperDetails PaperAuthor PaperFetch Memory PlayAudio Skill ``` +`Memory` is left out where the model is shown no memory index: under +`--ephemeral`, or a recipe without the `memory` section. + `Wiki` is available only when requested explicitly, for example `--tools Read Grep Wiki`. diff --git a/docs/security.md b/docs/security.md index 76869f24..29cc79e8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -48,7 +48,7 @@ sagent --ephemeral sagent --session /tmp/disposable-sagent-session ``` -`--ephemeral` also disables the auto-memory system prompt section, so existing project memories are not sent to providers and the model is not instructed to write new memories. Library users can leave `session_dir` unset for in-memory runs; call `build_system_dict(..., include_memory=False)` when persistent memory should be disabled too. +`--ephemeral` also disables the auto-memory system prompt section, so existing project memories are not sent to providers and the model is not instructed to write new memories. Library users can leave `session_dir` unset for in-memory runs; pass `prompt_context=SagentContext(include_memory=False)` to `Agent` when persistent memory should be disabled too. Large tool outputs may be persisted separately to keep model requests within budget. Session runs store those under `session_dir/tool-results`; no-session runs use `/tmp/sagent_results`. Persisted files are written with owner-only permissions. diff --git a/docs/sessions.md b/docs/sessions.md index 40544bf4..81c4c120 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -55,7 +55,7 @@ sagent --resume-all sagent --ephemeral ``` -Use this for prompts that should not write conversation state or auto-memory to disk. Library users can omit `session_dir` for in-memory runs and pass `include_memory=False` to `build_system_dict` when persistent memory should be disabled too. +Use this for prompts that should not write conversation state or auto-memory to disk. Library users can omit `session_dir` for in-memory runs and pass `prompt_context=SagentContext(include_memory=False)` to `Agent` when persistent memory should be disabled too. No-session runs may still persist oversized tool results temporarily under `/tmp/sagent_results` when needed to keep model requests within context budgets. diff --git a/docs/tools.md b/docs/tools.md index 584ad7cf..19d33b5f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -14,7 +14,7 @@ A tool exposes: - `directive_schema`: JSON Schema for input directives. - `supports_microcompaction`: whether old results may be shortened. - `summary(msg)`: short label for UI/status output. -- `prompt()`: optional per-request system prompt section. +- `prompt()`: optional system prompt section, read when the system blocks are built: at the start of a context (clear, compaction, resume), and when `replace_tool` swaps in a tool with a different one. - `run(msg)`: async execution. Batch tools return one `Message`. Streaming tools set `streaming = True` and make `run(msg)` an async generator; yielded messages before the last are intermediate events, and the last yield is the final tool result. @@ -92,6 +92,7 @@ class EchoTool: | `PaperDetails` | Paper metadata, references, citations. | `id`; optional `operation`. | | `PaperAuthor` | Author search and author papers. | `query` or `id`; optional `operation="papers"`. | | `PaperFetch` | Download paper PDFs. | `id`. | +| `Memory` | Save or forget a project memory, keeping `MEMORY.md` in step. | `action`, `name`; a save also `type`, `description`, `content`. | | `AgentSelf` | Let an agent inspect/mutate itself. | Flat patch object; all fields optional. | | `AgentSpawn` | Run child agents. | `prompt`; optional model/tools/depth/session controls. | | `AgentSend` | Send to a live named agent inbox. | `to`, `content`; optional `delay`. | @@ -199,7 +200,7 @@ Paper tools use Semantic Scholar, OpenAlex, arXiv, and open-access PDF metadata. `AgentSpawn` creates child agents. Children can inherit the parent model/tools or override provider, auth, model ID, account, tools, max tool-call rounds, and max depth. With `persistent=true`, a child stays alive and can receive messages through `AgentSend`. -By default (`hot=false`, "cold"), a child rebuilds its own system prompt from its own tools each request -- it auto-gains `BackgroundTask` alongside `AgentSpawn` and shows its own spawn-depth text, both of which differ from the parent's prompt from the first request and defeat provider prompt caching. Pass `hot=true` to instead freeze the child's system prompt to a byte-identical snapshot of the parent's current one, letting the child's first request land on the same cached prefix the parent already warmed. Trade-off: a hot child's prompt won't reflect its own live depth budget or a freshly bundled tool. Prefer hot for cheap, frequent, or parallel children (lookups, reviews) that don't need that dynamism. +A child with its parent's tools builds its system blocks from its parent's settings, so its first request reads the prefix the parent already cached -- unless what the blocks render changed since the parent last built them (its directory, an `AGENTS.md`, a saved memory). What differs per agent -- its spawn depth, its label, the agents it can message, a persistent child's reply rule -- is told as a reminder with its first turn and retold when it changes, never in the system blocks. Persistent subagents have a durable lifecycle. The parent session records each child's `kind=persistent_subagent` entry with a stable diff --git a/examples/agent_maze/tools.py b/examples/agent_maze/tools.py index 1477966b..1e281fb2 100644 --- a/examples/agent_maze/tools.py +++ b/examples/agent_maze/tools.py @@ -211,10 +211,14 @@ def summary(self, args: Mapping[str, object]) -> str: return "comms broadcast" def prompt(self) -> str: - """Return the tool prompt for the agent. + """Return no fixed guidance; teammates come and go, so they are a reminder.""" + return "" + + def reminder(self) -> str: + """Return who this agent is and which teammates it can reach. Returns: - prompt: Prompt describing the agent's reachable teammates. + reminder: The agent's label and its reachable teammates. """ me = agent_label_var.get("") diff --git a/examples/agent_maze/tools_test.py b/examples/agent_maze/tools_test.py index c07aa703..7a30b1d3 100644 --- a/examples/agent_maze/tools_test.py +++ b/examples/agent_maze/tools_test.py @@ -194,6 +194,25 @@ def fake(parent: str, xy: tuple[int, int]) -> str: _clear(["a0"]) +def test_comms_tells_reachable_peers_as_a_reminder() -> None: + """The system blocks are built once; teammates spawned later need retelling.""" + eng = _engine() + _ = _register(["a0", "a1"]) + tok = agent_label_var.set("a0") + try: + mesh = CommsTool(eng, mesh=True) + assert mesh.prompt() == "" + assert mesh.reminder() == "You are 'a0'. Teammates currently reachable: ['a1']." + tree = CommsTool(eng, mesh=False, coordinator="lead") + assert ( + tree.reminder() + == "You are 'a0'. You may only message the coordinator 'lead'." + ) + finally: + agent_label_var.reset(tok) + _clear(["a0", "a1"]) + + if __name__ == "__main__": from sagent.lib.testing.runner import test_main diff --git a/sagent/agent/__init__.py b/sagent/agent/__init__.py index ee405445..c85d7c51 100644 --- a/sagent/agent/__init__.py +++ b/sagent/agent/__init__.py @@ -36,7 +36,6 @@ ERROR_MAX_TOOL_CALL_ROUNDS, ActivityTracker, Agent, - SystemPromptArg, ) from sagent.agent.background import BackgroundTaskEntry from sagent.agent.compaction import CompactionState @@ -48,5 +47,4 @@ "Agent", "BackgroundTaskEntry", "CompactionState", - "SystemPromptArg", ] diff --git a/sagent/agent/agent.py b/sagent/agent/agent.py index 8e7606f7..d92cdef8 100644 --- a/sagent/agent/agent.py +++ b/sagent/agent/agent.py @@ -29,10 +29,9 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence from pathlib import Path from types import MappingProxyType -from typing import TYPE_CHECKING, Final, Literal, NoReturn +from typing import TYPE_CHECKING, Final, Literal, NoReturn, Protocol import asyncio import contextlib @@ -95,7 +94,7 @@ from sagent.lib.tool_validation import validate_tool_input from sagent.providers import providers from sagent.providers.lib.errors import PolicyBlockedError -from sagent.request_materialization import materialize_request +from sagent.request_materialization import materialize_messages # The one module imported whole rather than by name: this file touches 36 # of its symbols, and the flat list buried every other import here. @@ -141,16 +140,21 @@ DEFAULT_MAX_RESULT_CHARS, MAX_RESULT_TOKENS, MAX_ROUND_RESULT_CHARS, + Reminding, ResultBounded, Tool, ToolResultPolicy, + gate_refusal, ) if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Callable, Generator, Mapping, Sequence + import contextvars from sagent.types.capability import ( + ContextTag, ModelCapability, ModelLimits, ModelSettings, @@ -162,10 +166,6 @@ logger = logging.getLogger(__name__) -SystemPromptArg = str | Callable[[], str] -"""System-prompt spec. ``str`` is literal; ``Callable[[], str]`` is -re-invoked per request so cwd-aware sections stay live after ``cd``.""" - ERROR_MAX_TOOL_CALL_ROUNDS: Final = "error:max_tool_call_rounds" REFUSED_RESULT: Final = "[withheld: the model refused the request carrying this result]" MAX_OVERFLOW_RECOVERY = 3 # house-ignore[globals] -- Overflow-recovery retry count. @@ -223,14 +223,6 @@ def _settings_from_knobs( return budget -def _reject_bad_system_arg(system: object) -> None: - """Reject non-system-prompt constructor values before runtime setup.""" - if not isinstance(system, str) and not callable(system): - raise TypeError( - f"system must be str or Callable[[], str], got {type(system).__name__}", - ) - - @dataclasses.dataclass(kw_only=True, slots=True) class ActivityTracker: """Lifecycle counters for the status pane.""" @@ -264,14 +256,39 @@ class ActivityTracker: _TOOL_REGISTRY_MAX: Final = 10_000 +class PromptContext(Protocol): + """Renders what an agent tells its model about the project and environment. + + Injected so the agent layer does not depend on a host's prompt assets. The + sagent CLI's is ``prompt.SagentContext``. + """ + + def project(self, cwd: str, *, additional_dirs: Sequence[str]) -> str: + """Return the project instructions for ``cwd``; sent in the system blocks.""" + ... + + def environment( + self, + capability: ModelCapability, + *, + cwd: str, + context: ContextTag, + ) -> str: + """Return the environment; retold to the model whenever it changes.""" + ... + + class Agent: """Conversation agent: composes :class:`AgentRuntime` with wrappers. Args: model: Rich provider model the agent calls. model_recipe: Optional spec recording how the model was built. - system: Static system prompt string or a no-arg factory rebuilt - each request. + system: Base system prompt; replaces nothing else the agent adds. + append_system: Text placed after ``system``, ahead of the tool prompts. + prompt_context: Renders the project instructions (a last system block) + and the environment (a reminder, retold when it changes); ``None`` + sends neither. tools: Rich tools advertised to the model. compactor: Rich compactor used on ``types.runtime.Compact`` / ``types.runtime.Recompact`` and on overflow recovery. @@ -288,14 +305,6 @@ class Agent: forces ``types.runtime.ModelResponseError``. max_budget_usd: Hard USD cap; ``record_response`` raises when hit. persistent_retry: Enable persistent-mode backoff for 429/529. - frozen_system: Skip per-tool prompt contributions and the detached- - activity note in :meth:`_build_system`, returning ``system`` - verbatim instead. Trades the liveness ``_build_system`` normally - gives (fresh depth/budget reminders, newly-bundled tools, the - detached note) for a byte-stable prompt a provider's prefix cache - can reuse across otherwise-divergent agents. Set by ``AgentSpawn`` - hot spawns (see ``tools.agent_spawn``); not meant to be toggled by - general callers mid-session. allow_background: Whether a tool call may carry ``background`` / ``delay``. A host whose turn must end with its reply sets it False: tools are then offered without those keys, and a call that sends them runs in @@ -322,7 +331,9 @@ def __init__( *, model: Model, model_recipe: ModelRecipe | None = None, - system: SystemPromptArg = "", + system: str = "", + append_system: str = "", + prompt_context: PromptContext | None = None, tools: list[Tool] | None = None, compactor: Compactor | None = None, session_dir: str | Path | None = None, @@ -333,12 +344,10 @@ def __init__( max_tool_call_rounds: int | None = None, max_budget_usd: float | None = None, persistent_retry: bool = False, - frozen_system: bool = False, allow_background: bool = True, tool_results: ToolResultPolicy | None = None, owned_provider: Provider | None = None, ) -> None: - _reject_bad_system_arg(system) budget = _settings_from_knobs( budget, max_attempts=max_attempts, @@ -352,9 +361,29 @@ def __init__( self.model_recipe = model_recipe if model_recipe is not None: last_models.record(model_recipe.provider, model_recipe.model_id) - self._base_system_spec: SystemPromptArg = system - self._system_spec: SystemPromptArg = system - self._frozen_system = frozen_system + # What the system blocks are built from. Set before the first request; + # a later change shows only once the context is rebuilt (clear, compaction). + self.system = system + self.append_system = append_system + self.prompt_context = prompt_context + # Built once per context window: a provider caches the request up to + # each block's end, so a block rebuilt mid-session re-caches everything. + self._system_blocks: tuple[str, ...] | None = None + self.reminders: dict[str, str] = {} + """Standing context a host keeps in force, by name. Told with the next + round and retold whenever it changes or leaves the context (a + compaction, a ``/clear``, a refused turn); emptying or deleting one + tells the model it no longer holds.""" + # The last text told for each piece of standing context -- the environment, + # each ``Reminding`` tool, each host reminder -- kept only while the context + # still holds it. + self._told: dict[tuple[str, str], str] = {} + # The gate the model was last offered, and the one each response's calls were + # offered, by call id. A call is checked against its own: a host narrowing + # ``tool_gate`` mid-round, or a backgrounded call starting after the next + # round's offer, must not refuse what the model was offered. + self._offered_gate: frozenset[str] | None = None + self._offered_by_call: dict[str, frozenset[str] | None] = {} self.allow_background = allow_background self._tools_list: list[Tool] = list(tools or []) self.compactor = compactor @@ -437,8 +466,11 @@ def __init__( self._tools_version: int = 0 self._live_tools_cache: tuple[int, list[Tool]] | None = None self.tool_gate: frozenset[str] | None = None - """Names of the only tools the next model requests offer; ``None`` offers all. - A host sets it for a call and clears it after; tool calls still run by name.""" + """Names of the only tools the model is offered now; ``None`` offers all. + Every schema still ships, so the request's cached prefix survives a + change: the model is told of it, and the request names it for a + provider that can enforce it. A call outside the gate its request + offered is refused, whatever the provider enforced.""" agent_tools: list[_AgentTool] = [] for t in self._tools_list: self._tools_map[t.name] = t @@ -462,6 +494,7 @@ def __init__( # infra (REPL pump, watchdogs) live indefinitely by design; # counting them would wedge a one-shot ``Agent.run`` forever. self.runtime.has_pending_background = self._has_pending_background + self.runtime.context_updates = self._context_updates for fn in ( self._track_activity, self._track_tool_registry, @@ -781,6 +814,24 @@ def tools(self) -> list[Tool]: """Rich tools in registration order (pre-wrap copies).""" return list(self._tools_map.values()) + @property + def offered_gate(self) -> frozenset[str] | None: + """The tools the model's latest request offered; ``None`` offered them all.""" + return self._offered_gate + + def offered_to(self, call_id: str) -> frozenset[str] | None: + """Return the tools the request that made ``call_id`` offered. + + Args: + call_id: The call; one no response made (a direct run) gets the + latest offer. + + Returns: + offered: Those tools; ``None`` offered them all. + + """ + return self._offered_by_call.get(call_id, self._offered_gate) + def live_tools(self) -> list[Tool]: """Return the provider-visible tool surface for model requests. @@ -807,24 +858,7 @@ def live_tools(self) -> list[Tool]: ], ) self._live_tools_cache = cached - gate = self.tool_gate - return [tool for tool in cached[1] if gate is None or tool.name in gate] - - @property - def system(self) -> str: - """Assembled system prompt (base + per-tool contributions unless frozen).""" - return self._build_system() - - @property - def base_system_spec(self) -> str: - """Base system prompt before tool or persistent IPC augmentation.""" - spec = self._base_system_spec - return spec if isinstance(spec, str) else spec() - - @property - def frozen_system(self) -> bool: - """Whether :meth:`system_prompt` returns the spec verbatim (hot spawn).""" - return self._frozen_system + return list(cached[1]) @property def max_attempts(self) -> int: @@ -928,6 +962,10 @@ def replace_tool(self, name: str, tool: Tool) -> None: raise ValueError( f"tool.name={tool.name!r} does not match registry key {name!r}", ) + # New guidance must reach the model, at the cost of the cache; the same + # guidance (a host rewrapping its tools each turn) costs nothing. + if (tool.prompt() or "") != (self._tools_map[name].prompt() or ""): + self._system_blocks = None self._tools_map[name] = tool self._tools_version += 1 wrapper = self.runtime.tools_map.get(name) @@ -1194,15 +1232,36 @@ async def relogin(self) -> None: if self.runtime.model_call is not None: self.runtime.inbox.push_back(runtime.Halt()) - def system_prompt(self) -> str: - """Assemble the full system prompt (system + tool contributions). + def system_blocks(self) -> tuple[str, ...]: + """Return the system blocks, most stable first, built once per context. + + The blocks are the detached-results note and ``system``; then + ``append_system`` and every tool's prompt; then AGENTS.md and the memory + index. A provider marks the end of each, so agents that share the + leading blocks share their cache. They are rebuilt when the context is + (clear, compaction, resume) and when ``replace_tool`` changes a tool's prompt. + What the agent tells as reminders -- the environment, the tool and host + reminders, the tool gate -- reaches the model between rebuilds; a change + to ``system``, ``append_system`` or AGENTS.md waits for the next one. Returns: - prompt: System prompt rebuilt for the next request, or the - frozen spec verbatim when ``frozen_system`` is set. + blocks: The non-empty blocks, in order. """ - return self._build_system() + if self._system_blocks is None: + # The Memory tool saves where this index was read, not where ``cd`` went. + self.tool_state.project_cwd = self.tool_state.bash_cwd + self._system_blocks = self._build_system_blocks() + return self._system_blocks + + def system_prompt(self) -> str: + """Return the system blocks joined, as one prompt.""" + return "\n\n".join(self.system_blocks()) + + def retell_context(self) -> None: + """Append, as a system message, what the model's context no longer says.""" + for note in self._context_updates(): + self.runtime.append_history(note) def resume( self, @@ -1233,6 +1292,7 @@ def resume( self.runtime.session_id = meta.session_id self.runtime.replay_tape(tape) self.tool_state = tool_state + self._reset_system() if meta.status: self._status = meta.status self.cost_tracker.restore_totals(spend=meta.spend, total=meta.tokens) @@ -1766,56 +1826,116 @@ def resolver(ev: runtime.RuntimeEvent) -> None: if resolver in self.runtime.observers: self.runtime.observers.remove(resolver) - # Intentionally rebuilt per request: ``_system_spec`` and each tool's ``prompt()`` - # may be callable and capture mutable state (cwd, registered subagents, persistent - # IPC peers) that must stay live across ``cd`` and registry mutations. Caching here - # would freeze that state on the next provider call. + # The detached note leads, ahead of ``system``, so a caller's replacement prompt + # cannot drop it: the model must know ``DetachedArrived`` turns are deliveries, + # not a callable tool, before the first one lands (it copied the pattern when the + # note arrived with it). Paired with the runtime guard in ``_run_tool_and_post``. + # An agent with no tools never detaches, so it goes without. + def _build_system_blocks(self) -> tuple[str, ...]: + """Render the system blocks from the current settings, tools, and directory.""" + note = runtime.DETACHED_ARRIVED_SYSTEM_NOTE if self._tools_map else "" + tool_prompts = [tool.prompt() or "" for tool in self._tools_map.values()] + project = ( + self.prompt_context.project( + self.tool_state.project_cwd, + additional_dirs=self.tool_state.additional_dirs, + ) + if self.prompt_context is not None + else "" + ) + blocks = ( + "\n\n".join(p for p in (note, self.system) if p), + "\n\n".join(p for p in (self.append_system, *tool_prompts) if p), + project, + ) + return tuple(b for b in blocks if b) + + # The system blocks are fixed between rebuilds, so whatever changes under them + # reaches the model here, appended to the next round's input; a reminder stays in + # history and is cached with it, where a rewritten block re-caches everything. # - # Exception: ``self._frozen_system`` (set by a hot ``AgentSpawn``) opts OUT of that - # liveness on purpose -- ``_system_spec`` already holds a complete, pre-rendered - # snapshot (typically the parent's own ``system`` at spawn time), and re-appending - # this agent's own tools' ``prompt()`` contributions on top would immediately - # reintroduce the prefix drift hot spawning exists to avoid (see #361: a bundled - # ``BackgroundTask`` or a different spawn-depth line the parent never had). Returned - # verbatim, with no tool contributions or detached-note injection. - def _build_system(self) -> str: - """Assemble the system prompt from base spec + tool contributions.""" - spec = self._system_spec - base = spec if isinstance(spec, str) else spec() - if self._frozen_system: - return base - parts: list[str] = [base] if base else [] + # What the model knows is what its context holds, and a compaction, a ``/clear``, + # a refused turn or a resume rewrites that without telling this agent. So a text + # counts as told only while the context still contains it, and the gate in force + # is the last one the context offered. + def _context_updates(self) -> list[runtime.SystemMessage]: + """Return a system message for each thing the model's context does not say.""" + messages = self.runtime.context().messages + notes = [ + runtime.SystemMessage(text=text) + for text in self._standing_updates(messages) + ] + gate = self._gate_update(messages) + return notes if gate is None else [*notes, gate] + + # Each piece of standing context is a message of its own, told while one says it + # exactly: text found inside another (a peer list one name shorter) was never told. + # One that empties or goes is withdrawn, or the old text stays in force. + def _standing_updates( + self, + messages: Sequence[runtime.ModelContextEvent], + ) -> list[str]: + """Return the standing context the context lacks, and what it no longer has.""" + said = {m.text for m in messages if isinstance(m, runtime.SystemMessage)} + told = {key: text for key, text in self._told.items() if text in said} + now = {key: text for key, text in self._standing_context().items() if text} + self._told = now + return [ + *( + text + for key, text in now.items() + if (told[key] != text if key in told else text not in said) + ), + *( + f"No longer in force: {text}" + for key, text in told.items() + if key not in now + ), + ] + + def _gate_update( + self, + messages: Sequence[runtime.ModelContextEvent], + ) -> runtime.SystemMessage | None: + """Return the offer of the gate in force, unless the context already makes it.""" + names = tuple(self._tools_map) gate = self.tool_gate - for tool in self._tools_map.values(): - if gate is not None and tool.name not in gate: - continue - contribution = tool.prompt() or "" - if contribution: - parts.append(contribution) - # Teach the model that ``DetachedArrived`` history turns are runtime - # result deliveries, not a callable tool. Injected the moment any tool - # detaches -- before the first synthesized ``DetachedArrived`` pair - # lands -- so the prohibition precedes the imitable pattern rather than - # co-occurring with it (the reactive injection taught the model the - # exact shape it then copied). Prompts without detached activity stay - # lean. Paired with the runtime guard in ``_run_tool_and_post``. - if self._has_detached_activity(): - parts.append(runtime.DETACHED_ARRIVED_SYSTEM_NOTE) - return "\n\n".join(parts) - - # Proactive: a live ``runtime.detached`` task means a ``[detached]`` stub is already - # in context and a forward delivery is pending, so the note must appear now. The - # history check covers the window after the task completed but its synthesized pair - # still resolves into context. - def _has_detached_activity(self) -> bool: - """Return True when a tool is detached or a ``DetachedArrived`` turn is present.""" - if self.runtime.detached: - return True - return any( - isinstance(entry, runtime.AssistantMessage) - and any(tc.name == runtime.DETACHED_ARRIVED_TOOL for tc in entry.tool_calls) - for entry in self.runtime.context().messages + wanted = names if gate is None else tuple(n for n in names if n in gate) + self._offered_gate = None if gate is None else frozenset(wanted) + if set(_offer_in_force(messages, names)) == set(wanted): + return None + return runtime.SystemMessage( + text="All tools are available again." + if self._offered_gate is None + else f"Tools available now: {', '.join(sorted(wanted)) or 'none'}." + " Do not call any other tool.", + offered_tools=wanted, + ) + + def _standing_context(self) -> dict[tuple[str, str], str]: + """Return the environment, tool reminders and host reminders, keyed by source.""" + environment = ( + self.prompt_context.environment( + self.model.capability, + cwd=self.tool_state.bash_cwd, + context=self.model.settings.context, + ) + if self.prompt_context is not None + else "" ) + return { + ("environment", ""): environment, + **{ + ("tool", name): tool.reminder() + for name, tool in self._tools_map.items() + if isinstance(tool, Reminding) + }, + **{("host", name): text for name, text in self.reminders.items()}, + } + + def _reset_system(self) -> None: + """Rebuild the system blocks on the next request.""" + self._system_blocks = None # -- Observers ---------------------------------------------------- @@ -2101,6 +2221,7 @@ def _track_tool_registry(self, event: runtime.RuntimeEvent) -> None: now = time.time() for tc in event.message.tool_calls: self._tool_registry[tc.id] = (tc.name, now) + self._offered_by_call[tc.id] = self._offered_gate if event.message.tool_calls: self.activity.num_tool_call_rounds += 1 self._round_result_chars = 0 @@ -2123,10 +2244,16 @@ def _prune_tool_registry(self) -> None: ) for _started, call_id in stale[: len(self._tool_registry) - _TOOL_REGISTRY_MAX]: _ = self._tool_registry.pop(call_id, None) + _ = self._offered_by_call.pop(call_id, None) def _track_compaction(self, event: runtime.RuntimeEvent) -> None: """Update compaction state after a barrier lands.""" + # A new context window re-caches from scratch anyway, so it is when the + # system blocks catch up with what changed under them. + if isinstance(event, runtime.ClearComplete): + self._reset_system() if isinstance(event, runtime.CompactComplete) and event.records: + self._reset_system() self._last_input_tokens = 0 self._last_measured_history = () self.tool_state.reset_tool_recall() @@ -2394,22 +2521,24 @@ def _estimated_request_tokens( if not since: return self._last_input_tokens return self._last_input_tokens + model.approx_request_tokens( - materialize_request( - ModelRequest(messages=list(since)), - tool_result_budget_tokens=budget, + ModelRequest( + messages=materialize_messages( + since, + tool_result_budget_tokens=budget, + ), ), ) # Estimated against ``live_tools()`` (``BackgroundAwareTool`` wrappers # applied) so the injected ``background`` / ``delay`` schema counts toward # the budget, matching what the next request will carry. return model.approx_request_tokens( - materialize_request( - ModelRequest( - messages=list(history), - system=self.system_prompt(), - tools=self.live_tools(), + ModelRequest( + messages=materialize_messages( + history, + tool_result_budget_tokens=budget, ), - tool_result_budget_tokens=budget, + system=self.system_blocks(), + tools=self.live_tools(), ), ) @@ -2422,7 +2551,7 @@ def _estimated_request_tokens( # tool's rendered-byte cap. # # Measured over the materialized prefix so attachments on tool results that - # ``materialize_request`` drops are not counted, and via + # ``materialize_messages`` drops are not counted, and via # :func:`_wire_attachment_bytes` so the estimate matches the base64, post-resize # payload that actually ships. def _compactable_wire_bytes( @@ -2435,12 +2564,12 @@ def _compactable_wire_bytes( if last_assistant is None: return 0 prefix = history[: last_assistant + 1] - materialized = materialize_request( - ModelRequest(messages=list(prefix)), + materialized = materialize_messages( + prefix, tool_result_budget_tokens=self.tool_results.message_budget_tokens, ) return _wire_attachment_bytes( - materialized.messages, + materialized, max_image_bytes=model.limits.max_image_bytes, ) @@ -2530,6 +2659,23 @@ async def compact_now(self) -> bool: return True +# The context's own record of the gate: the last offer any system message made, or every tool +# when none did, so a compaction or resume that drops the offer shows here. +def _offer_in_force( + messages: Sequence[runtime.ModelContextEvent], + every: tuple[str, ...], +) -> tuple[str, ...]: + """Return the tools ``messages`` last offered; ``every`` when none offered any.""" + return next( + ( + m.offered_tools + for m in reversed(messages) + if isinstance(m, runtime.SystemMessage) and m.offered_tools is not None + ), + every, + ) + + def _latest_service_retry_at( events: Sequence[runtime.RuntimeEvent], ) -> float | None: @@ -2649,11 +2795,11 @@ def _wire_attachment_bytes( # so the estimate stays a conservative LOWER bound that never false-rejects a request # the provider would accept. # -# Callers must pass the MATERIALIZED messages (post :func:`materialize_request`), so +# Callers must pass the MATERIALIZED messages (post :func:`materialize_messages`), so # elided tool results are sized at their wire placeholder rather than their pre-elision # length -- otherwise the guard over-counts bytes the request never carries. def _wire_request_bytes( - messages: Sequence[runtime.ModelContextEvent], + messages: Sequence[runtime.WireMessage], *, system: str, max_image_bytes: int, @@ -2662,10 +2808,7 @@ def _wire_request_bytes( total = _wire_attachment_bytes(messages, max_image_bytes=max_image_bytes) total += len(system.encode("utf-8")) for entry in messages: - if isinstance( - entry, - (runtime.UserMessage, runtime.AgentSendMessage), - ): + if isinstance(entry, (runtime.UserMessage, runtime.AgentSendMessage)): total += len(entry.text.encode("utf-8")) elif isinstance(entry, runtime.AssistantMessage): total += len(entry.text.encode("utf-8")) @@ -2962,9 +3105,8 @@ async def stream( ``send_with_retry`` with persistent-mode + overflow recovery, records cost out-of-band, returns the final assistant message. - ``system`` and ``tools`` come from agent state (not the runtime) - so callable system specs (e.g. cwd-aware ``environment``) and - registry mutations stay live across the call. + ``system`` and ``tools`` come from agent state (not the runtime): + the blocks it built for this context and its registered tools. Args: history: Conversation history (resolved view passed by runtime). @@ -2996,19 +3138,15 @@ async def stream( # to the runtime tape; we refetch the resolved view below. if not await self._agent.compact_if_needed(history, self._inner): self._raise_compact_failure() + # The runtime told the context before this call; a summary just taken drops + # what it told, and this request would go without it. + self._agent.retell_context() # Compaction may have appended a barrier override; refetch the # resolved view so subsequent attempts in this call see it. history = self._agent.runtime.context().messages - # Compute the system prompt once per ``stream`` call. Each tool's - # ``prompt()`` may capture cwd / registry / persistent-IPC state, - # but none of that state changes between overflow-recovery - # attempts within a single stream call -- so rebuilding up to - # ``MAX_OVERFLOW_RECOVERY + 1`` times per call is wasted work. - # ``Agent.system_prompt()`` callers outside this loop continue to - # observe live state via ``_build_system`` on demand. Computed before - # the pre-send guard so the guard can count the system-prompt bytes. - cached_system = self._agent.system_prompt() + # Read after compaction, which rebuilds the blocks for the new context. + system = self._agent.system_blocks() # Pre-send byte guard: compaction has now shed every attachment it # can (the summarized region). If the request STILL exceeds the @@ -3030,13 +3168,13 @@ async def stream( # still hits the reactive 413. max_request_bytes = self._inner.limits.max_request_bytes if max_request_bytes > 0: - guard_messages = materialize_request( - ModelRequest(messages=list(history)), + guard_messages = materialize_messages( + history, tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, - ).messages + ) wire_bytes = _wire_request_bytes( guard_messages, - system=cached_system, + system="\n\n".join(system), max_image_bytes=self._inner.limits.max_image_bytes, ) if wire_bytes > max_request_bytes: @@ -3065,15 +3203,15 @@ async def stream( observed_model = ObservedModel(self._inner, self._agent.record_response) for attempt in range(MAX_OVERFLOW_RECOVERY + 1): - request = materialize_request( - ModelRequest( - messages=list(history), - system=cached_system, - tools=rich_tools, - max_response_tokens=self._agent.max_response_tokens, - cache_key=self._agent.session_id, + request = ModelRequest( + messages=materialize_messages( + history, + tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, ), - tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, + system=system, + tools=rich_tools, + max_response_tokens=self._agent.max_response_tokens, + cache_key=self._agent.session_id, ) # Hand the one-shot resume wait to THIS attempt only, then clear it # unconditionally -- before the send can raise. ``send_with_retry`` @@ -3145,6 +3283,7 @@ async def stream( # lines followed by a cryptic RuntimeError). if not await self._agent.compact_now(): self._raise_compact_failure() + self._agent.retell_context() # Refetch resolved view: ``compact_now`` appended a # barrier override. history = self._agent.runtime.context().messages @@ -3247,6 +3386,13 @@ async def run(self, args: Mapping[str, object]) -> runtime.ToolResult: # ``ToolLabel`` / ``ToolResult`` records correlate to the # originating assistant tool_use. call_id = current_call_id_var.get("") + # Every schema ships, so a model can name a tool its request withheld; a + # provider that cannot enforce the gate would run it. + if refusal := gate_refusal(self._inner.name, self._agent.offered_to(call_id)): + self._agent.runtime.publish( + runtime.ToolLabel(call_id=call_id, text=self._inner.name), + ) + return runtime.ToolResult(call_id=call_id, content=refusal, is_error=True) # The raw args are validated against the schema they were offered, control # keys included, so a mistyped ``delay`` is an input error rather than a # decode failure inside ``split_bg_args``. A tool that owns a control key @@ -3538,7 +3684,7 @@ async def compact( # Memoize system prompt + live tools for the two token-estimate # request builds below: agent state does not change inside one # ``compact`` call, so rebuilding both per estimate is wasted work. - cached_system = self._agent.system_prompt() + cached_system = self._agent.system_blocks() cached_tools = self._agent.live_tools() # Post-compact enrich operates on the override's mutable payload @@ -3548,13 +3694,13 @@ async def compact( # ``post_compact_enrich``. try: used = self._agent.model.approx_request_tokens( - materialize_request( - ModelRequest( - messages=payload, - system=cached_system, - tools=cached_tools, + ModelRequest( + messages=materialize_messages( + payload, + tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, ), - tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, + system=cached_system, + tools=cached_tools, ), ) except Exception as exc: # noqa: BLE001 -- The estimator invokes provider classification during recovery, so failures route to warning or exception handling. @@ -3605,15 +3751,15 @@ async def compact( if target > 0: try: payload_tokens_for_scrunch = self._agent.model.approx_request_tokens( - materialize_request( - ModelRequest( - messages=payload, - system=cached_system, - tools=cached_tools, - ), - tool_result_budget_tokens=( - self._agent.tool_results.message_budget_tokens + ModelRequest( + messages=materialize_messages( + payload, + tool_result_budget_tokens=( + self._agent.tool_results.message_budget_tokens + ), ), + system=cached_system, + tools=cached_tools, ), ) except Exception as exc: # noqa: BLE001 -- The estimator invokes provider classification during recovery. @@ -3677,19 +3823,21 @@ async def compact( payload_tokens: int | None = None try: payload_tokens = self._agent.model.approx_request_tokens( - materialize_request( - ModelRequest( - messages=payload, - system=cached_system, - tools=cached_tools, + ModelRequest( + messages=materialize_messages( + payload, + tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, ), - tool_result_budget_tokens=self._agent.tool_results.message_budget_tokens, + system=cached_system, + tools=cached_tools, ), ) would_retrigger = self._inner.should_compact( current_tokens=payload_tokens, largest_context=self._inner.largest_context(self._agent.budget), - system_tokens=self._agent.model.approx_text_tokens(cached_system), + system_tokens=self._agent.model.approx_text_tokens( + self._agent.system_prompt(), + ), ) if would_retrigger: msg = ( diff --git a/sagent/agent/agent_test.py b/sagent/agent/agent_test.py index 43297b8d..e31d0192 100644 --- a/sagent/agent/agent_test.py +++ b/sagent/agent/agent_test.py @@ -36,7 +36,6 @@ ActivityTracker, Agent, ObservedModel, - SystemPromptArg, _AgentCompactor, _AgentTool, _provider_knows_model, @@ -109,7 +108,6 @@ from sagent.types.runtime import ( CANCELLED_PLACEHOLDER, DETACHED_ARRIVED_SYSTEM_NOTE, - DETACHED_ARRIVED_TOOL, DETACHED_PLACEHOLDER, AgentIdle, AgentSendMessage, @@ -136,6 +134,7 @@ SaveSession, ServiceErrorSnapshot, StatusChanged, + SystemMessage, ToolCall, ToolLabel, ToolResult, @@ -428,7 +427,7 @@ def _build_agent( *, model: Model | None = None, tools: list[Tool] | None = None, - system: SystemPromptArg = "", + system: str = "", budget: AgentSettings | None = None, max_budget_usd: float | None = None, session_dir: Path | None = None, @@ -872,23 +871,6 @@ def test_agent_system_string_to_factory() -> None: assert a.system_prompt() == "hi there" -def test_agent_system_callable_passthrough() -> None: - calls: list[int] = [] - - def make_sys() -> str: - calls.append(1) - return f"call {len(calls)}" - - a = _build_agent(system=make_sys) - n_before = len(calls) - p1 = a.system_prompt() - p2 = a.system_prompt() - # Each ``system_prompt`` call re-invokes the factory; counters - # therefore advance by exactly two between the two calls. - assert p2 != p1 - assert len(calls) == n_before + 2 - - def test_agent_tools_map_stores_raw_tools() -> None: """H2: ``tools_map`` stores raw rich tools so isinstance / Protocol checks at consumer sites (CompactRestorable, Slack identity swap, @@ -964,27 +946,6 @@ def test_a_host_can_replace_the_tool_result_limits() -> None: assert a.tool_results.message_budget_tokens == 0 -@pytest.mark.asyncio -async def test_a_tool_gate_limits_the_tools_a_request_offers_until_it_is_cleared() -> ( - None -): - """The host sets ``tool_gate`` for a call and clears it after.""" - model = StubModel() - a = _build_agent(model=model, tools=[StubTool()]) - a.tool_gate = frozenset({"Nothing"}) - async for _ in a.run(UserMessage(text="hi")): - pass - hidden = model.received[-1] - assert not hidden.tools - a.tool_gate = frozenset({"Echo"}) - async for _ in a.run(UserMessage(text="again")): - pass - shown = model.received[-1] - assert [offered.name for offered in shown.tools] == ["Echo"] - a.tool_gate = None - assert [tool.name for tool in a.live_tools()] == ["Echo"] - - @pytest.mark.asyncio async def test_an_agent_that_forbids_background_offers_no_such_keys_and_runs_a_delay_now() -> ( None @@ -2160,23 +2121,6 @@ async def consume() -> None: await asyncio.wait_for(closed.wait(), timeout=1.0) -def test_system_prompt_arg_type_alias_str_or_callable() -> None: - # ``SystemPromptArg`` is exposed as a type alias for the constructor. - arg: SystemPromptArg = "hi" - assert arg == "hi" - - def factory() -> str: - return "x" - - arg2: SystemPromptArg = factory - assert callable(arg2) - - -def test_system_prompt_rejects_dict() -> None: - with pytest.raises(TypeError, match=r"system.*str or Callable"): - _build_agent(system=cast(SystemPromptArg, {"static": "literal"})) - - def test_max_request_tokens_setter_rejects_over_model_limit() -> None: a = _build_agent() with pytest.raises(ValueError, match="exceeds model's"): @@ -2343,49 +2287,20 @@ def test_total_cost_total_tokens_num_rounds_initially_zero() -> None: assert a.num_tool_call_rounds == 0 -def test_system_property_rebuilds_each_access() -> None: - """``Agent.system`` property re-runs ``_build_system``.""" +def test_an_agent_without_tools_sends_its_system_alone() -> None: a = _build_agent(system="root") - assert a.system == "root" + assert a.system_blocks() == ("root",) -def test_system_prompt_adds_detached_arrived_note_proactively() -> None: - """The ``DetachedArrived`` note appears the moment any tool detaches. +def test_the_detached_arrived_note_leads_before_any_tool_detaches() -> None: + """The prohibition must precede the imitable ``DetachedArrived`` pattern. - Proactive injection: the prohibition must precede the imitable - ``DetachedArrived`` pattern, not co-occur with it. So a live - ``runtime.detached`` task is enough to surface the note, before any - synthesized ``DetachedArrived`` turn exists. + It is in the first block from the first request, ahead of ``system``, so + a caller's replacement prompt cannot drop it and its arrival never + rewrites the cached prefix. """ - a = _build_agent(system="root") - # No detached activity yet -> lean prompt, no note. - assert DETACHED_ARRIVED_TOOL not in a.system - - loop = asyncio.new_event_loop() - try: - # A detached task alone (no DetachedArrived turn yet) -> note present. - det_task = loop.create_task(asyncio.sleep(0)) - a.runtime.detached["det-1"] = det_task - assert DETACHED_ARRIVED_SYSTEM_NOTE in a.system, ( - "note must appear while a tool is detached, before its forward " - "DetachedArrived pair lands" - ) - _ = det_task.cancel() - loop.run_until_complete(asyncio.gather(det_task, return_exceptions=True)) - finally: - loop.close() - a.runtime.detached.clear() - - # After the task completes, a synthesized DetachedArrived turn in history - # still surfaces the note. - a.runtime.append_history( - AssistantMessage( - tool_calls=( - ToolCall(id="x:detached", name=DETACHED_ARRIVED_TOOL, args={}), - ), - ), - ) - assert DETACHED_ARRIVED_SYSTEM_NOTE in a.system + a = _build_agent(system="root", tools=[StubTool()]) + assert a.system_blocks()[0] == f"{DETACHED_ARRIVED_SYSTEM_NOTE}\n\nroot" def test_background_merges_detached_and_explicit() -> None: @@ -3686,30 +3601,6 @@ def prompt(self) -> str: assert "(extra-tool-prompt)" in out -def test_build_system_frozen_returns_spec_verbatim() -> None: - """``frozen_system=True`` skips tool ``prompt()`` contributions entirely. - - This is what lets a hot ``AgentSpawn`` child (#361) keep a byte-identical - copy of its parent's rendered prompt: re-appending the child's OWN tools' - contributions on top of an already-rendered snapshot would reintroduce - the exact divergence hot spawning exists to avoid. - """ - - @dataclass(slots=True, kw_only=True) - class _PromptingTool(StubTool): - @override - def prompt(self) -> str: - return "(extra-tool-prompt)" - - a = Agent( - model=StubModel(), - system="root", - tools=[_PromptingTool()], - frozen_system=True, - ) - assert a.system_prompt() == "root" - - @pytest.mark.asyncio async def test_activity_active_spans_tool_execution() -> None: """``activity.active`` stays True from first types.runtime.ModelCallStarted through types.runtime.ModelIdle. @@ -5932,7 +5823,7 @@ def approx_text_tokens(self, text: str) -> int: @override def approx_request_tokens(self, request: ModelRequest) -> int: - total = len(request.system) // 4 + total = len(request.system_text) // 4 for m in request.messages: if isinstance(m, (UserMessage, AgentSendMessage)): total += len(m.text) // 4 @@ -7572,7 +7463,7 @@ async def stream( async def test_pre_send_guard_measures_materialized_not_raw_history() -> None: """The guard must size the MATERIALIZED request, not raw history. - A large HISTORICAL tool result is cut down by ``materialize_request``'s + A large HISTORICAL tool result is cut down by ``materialize_messages``'s tool-result budget (truncated to a resumable head, or elided outright when even that will not fit) before the request ships. Sizing raw history counts the un-shed bytes and false-rejects a request the provider would happily @@ -7826,21 +7717,6 @@ async def test_kill_tool_by_call_id_cancels_an_explicit_background_job() -> None assert a.background == {} -def test_a_gated_tool_contributes_no_system_prompt() -> None: - """A tool the request does not offer must not be described to the model.""" - - @dataclass(slots=True, kw_only=True) - class _Prompting(StubTool): - @override - def prompt(self) -> str: - return f"use {self.name}" - - a = _build_agent(tools=[_Prompting(name="Shown"), _Prompting(name="Hidden")]) - a.tool_gate = frozenset({"Shown"}) - assert "use Shown" in a.system - assert "use Hidden" not in a.system - - def test_a_finished_detached_call_frees_its_job_id() -> None: """Ids minted for detached calls were never freed: one leak per call.""" a = _build_agent() @@ -8249,54 +8125,553 @@ async def _boom(**kwargs: object) -> None: ) -def test_callable_system_composes_sections() -> None: - """Callable system can compose multiple named sections internally.""" +@dataclass(slots=True, kw_only=True) +class _PromptingTool(StubTool): + """A tool whose ``prompt()`` names it, counting how often it is asked.""" + + asked: list[int] = field(default_factory=list) + + @override + def prompt(self) -> str: + self.asked.append(1) + return f"use {self.name}" + + +@dataclass(slots=True, kw_only=True) +class _RewordedTool(_PromptingTool): + """A ``_PromptingTool`` reconfigured to say something else.""" + + @override + def prompt(self) -> str: + return f"use {self.name} carefully" - def factory() -> str: - return "\n\n".join(["static", _env()]) - def _env() -> str: - return "dynamic" +@dataclass(frozen=True, slots=True, kw_only=True) +class _Context: + """A prompt context naming the directory, as the sagent one does.""" - a = _build_agent(system=factory) - assembled = a.system_prompt() - assert "static" in assembled - assert "dynamic" in assembled + project_text: str = "" + def project(self, cwd: str, *, additional_dirs: Sequence[str]) -> str: + del cwd, additional_dirs + return self.project_text -def test_callable_system_re_evaluates_per_call() -> None: - """C4: callable system is re-evaluated on every ``system_prompt`` call.""" - counter = {"n": 0} + def environment( + self, + capability: ModelCapability, + *, + cwd: str, + context: str, + ) -> str: + del capability, context + return f"Working directory: {cwd}" - def factory() -> str: - counter["n"] += 1 - return f"call#{counter['n']}" - a = _build_agent(system=factory) - p1 = a.system_prompt() - p2 = a.system_prompt() - assert p1 != p2 +@pytest.mark.asyncio +async def test_the_project_instructions_are_the_last_system_block() -> None: + model = StubModel() + a = Agent(model=model, system="S", prompt_context=_Context(project_text="P")) + async for _ in a.run(UserMessage(text="hi")): + pass + assert model.received[-1].system == ("S", "P") @pytest.mark.asyncio -async def test_stream_rebuilds_system_per_request() -> None: - """C4: ``_AgentModel.stream`` must rebuild the system prompt each - call so cwd-aware sections stay live after ``cd``. - """ - call_count = 0 +async def test_the_system_blocks_are_note_and_prompt_then_append_and_tool_prompts() -> ( + None +): + """Each block ends where a provider may mark it, so its contents are fixed.""" + model = StubModel() + a = Agent( + model=model, + system="S", + append_system="A", + tools=[_PromptingTool(name="One"), _PromptingTool(name="Two")], + ) + async for _ in a.run(UserMessage(text="hi")): + pass + assert model.received[-1].system == ( + f"{DETACHED_ARRIVED_SYSTEM_NOTE}\n\nS", + "A\n\nuse One\n\nuse Two", + ) - def factory() -> str: - nonlocal call_count - call_count += 1 - return f"sys-v{call_count}" +@pytest.mark.asyncio +async def test_the_system_blocks_are_built_once_and_sent_unchanged() -> None: + """A rebuilt prompt broke the provider cache on every request that changed it.""" model = StubModel() - a = _build_agent(model=model, system=factory) - pre_run_count = call_count + tool = _PromptingTool() + a = Agent(model=model, system="S", tools=[tool]) + for text in ("one", "two"): + async for _ in a.run(UserMessage(text=text)): + pass + assert model.received[0].system == model.received[1].system + assert len(tool.asked) == 1 + + +@pytest.mark.asyncio +async def test_a_changed_directory_is_appended_and_leaves_the_system_blocks_alone( + tmp_path: Path, +) -> None: + """``cd`` rewrote the system prompt, so every later turn re-cached the history.""" + model = StubModel() + a = Agent(model=model, system="S", prompt_context=_Context()) + a.tool_state.bash_cwd = str(tmp_path) + async for _ in a.run(UserMessage(text="one")): + pass + moved = tmp_path / "sub" + moved.mkdir() + a.tool_state.bash_cwd = str(moved) + async for _ in a.run(UserMessage(text="two")): + pass + first, second = model.received[0], model.received[1] + assert first.system == second.system + assert f"Working directory: {tmp_path}" in _user_texts(first.messages)[0] + assert _user_texts(second.messages)[0] == _user_texts(first.messages)[0] + assert f"Working directory: {moved}" in _user_texts(second.messages)[-1] + + +@pytest.mark.asyncio +async def test_an_unchanged_environment_appends_nothing(tmp_path: Path) -> None: + model = StubModel() + a = Agent(model=model, system="S", prompt_context=_Context()) + a.tool_state.bash_cwd = str(tmp_path) + for text in ("one", "two"): + async for _ in a.run(UserMessage(text=text)): + pass + assert ( + "".join(_user_texts(model.received[-1].messages)).count("") + == 1 + ) + + +@pytest.mark.asyncio +async def test_the_agents_reminders_are_system_messages_not_the_humans_words() -> None: + """History keeps what the human typed as typed; the wire still carries both.""" + model = StubModel() + a = Agent(model=model, system="S", prompt_context=_Context()) + a.tool_state.bash_cwd = "/w" + async for _ in a.run(UserMessage(text="one")): + pass + typed, note, _ = a.history + assert isinstance(typed, UserMessage) + assert typed.text == "one" + assert note == SystemMessage( + id=note.id, + timestamp=note.timestamp, + text="Working directory: /w", + ) + assert _user_texts(model.received[-1].messages) == [ + "one\n\n\nWorking directory: /w\n", + ] + + +@dataclass(slots=True, kw_only=True) +class _RemindingTool(StubTool): + """A tool whose per-agent context is told as a reminder.""" + + told: str = "peers: a" + + def reminder(self) -> str: + return self.told + + +@pytest.mark.asyncio +async def test_a_tools_reminder_is_told_once_and_again_when_it_changes() -> None: + """Per-agent tool context stays out of the system blocks children share.""" + model = StubModel() + tool = _RemindingTool() + a = _build_agent(model=model, tools=[tool]) + for text in ("one", "two"): + async for _ in a.run(UserMessage(text=text)): + pass + tool.told = "peers: a, b" + async for _ in a.run(UserMessage(text="three")): + pass + told = "".join(_user_texts(model.received[-1].messages)) + assert told.count("peers: a\n") == 1 + assert "peers: a, b" in _user_texts(model.received[-1].messages)[-1] + assert all("peers" not in block for block in model.received[-1].system) + + +@pytest.mark.asyncio +async def test_a_tool_gate_keeps_every_schema_and_names_the_offered_tools() -> None: + """Removing schemas changed the request's first bytes; masking does not.""" + model = StubModel() + a = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + a.tool_gate = frozenset({"Echo"}) async for _ in a.run(UserMessage(text="hi")): pass - assert call_count > pre_run_count - assert model.received[-1].system == f"sys-v{call_count}" + request = model.received[-1] + assert [t.name for t in request.tools] == ["Echo", "Other"] + assert request.allowed_tools == frozenset({"Echo"}) + a.tool_gate = None + async for _ in a.run(UserMessage(text="again")): + pass + assert model.received[-1].allowed_tools is None + + +@pytest.mark.asyncio +async def test_a_tool_gate_change_is_appended_as_a_reminder() -> None: + model = StubModel() + a = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + async for _ in a.run(UserMessage(text="one")): + pass + a.tool_gate = frozenset({"Echo"}) + async for _ in a.run(UserMessage(text="two")): + pass + assert "Tools available now: Echo" in _user_texts(model.received[-1].messages)[-1] + + +@pytest.mark.asyncio +async def test_a_tool_gate_change_is_recorded_on_the_message_it_rides() -> None: + """A provider that withdraws tools mid-conversation does it at this message.""" + model = StubModel() + a = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + async for _ in a.run(UserMessage(text="one")): + pass + a.tool_gate = frozenset({"Echo"}) + async for _ in a.run(UserMessage(text="two")): + pass + a.tool_gate = None + async for _ in a.run(UserMessage(text="three")): + pass + offers = [ + m.offered_tools + for m in model.received[-1].messages + if isinstance(m, UserMessage) + ] + assert offers == [None, ("Echo",), ("Echo", "Other")] + # In history the gate is the harness's system message, never the human's message. + assert [m.offered_tools for m in a.history if isinstance(m, SystemMessage)] == [ + ("Echo",), + ("Echo", "Other"), + ] + + +def _calling(name: str) -> AssistantMessage: + """Return a response that calls the tool ``name`` once.""" + return AssistantMessage(tool_calls=(ToolCall(id="c1", name=name, args={}),)) + + +@pytest.mark.asyncio +async def test_a_call_outside_the_gate_it_was_offered_is_refused() -> None: + """Every schema ships, so a model can name a gated tool; the call must not run.""" + other = StubTool(name="Other") + a = _build_agent( + model=StubModel(responses=[_calling("Other")]), + tools=[StubTool(), other], + ) + a.tool_gate = frozenset({"Echo"}) + async for _ in a.run(UserMessage(text="hi")): + pass + assert other.calls == [] + [result] = [m for m in a.history if isinstance(m, ToolResult)] + assert result.is_error + assert result.content == "Not run: Other is not available now. Available: Echo." + + +@pytest.mark.asyncio +async def test_a_call_runs_under_the_gate_it_was_offered_though_the_host_narrows_it() -> ( + None +): + """A host narrows the gate as a response lands; that response's calls were offered.""" + other = StubTool(name="Other") + a = _build_agent( + model=StubModel(responses=[_calling("Other")]), + tools=[StubTool(), other], + ) + a.tool_gate = frozenset({"Echo", "Other"}) + + def narrow(event: RuntimeEvent) -> None: + if isinstance(event, runtime.ModelResponseComplete): + a.tool_gate = frozenset({"Echo"}) + + a.runtime.observers.append(narrow) + async for _ in a.run(UserMessage(text="hi")): + pass + assert other.calls == [{}] + + +@pytest.mark.asyncio +async def test_a_call_that_starts_after_the_next_round_runs_under_its_own_offer() -> ( + None +): + """A backgrounded call starts late; the next round's narrower gate is not its own.""" + other = StubTool(name="Other") + a = _build_agent(tools=[StubTool(), other]) + a.retell_context() + a.publish(runtime.ModelResponseComplete(message=_calling("Other"))) + a.tool_gate = frozenset({"Echo"}) + a.retell_context() + token = runtime.current_call_id_var.set("c1") + try: + result = await a.runtime.tools_map["Other"].run({}) + finally: + runtime.current_call_id_var.reset(token) + assert not result.is_error + assert other.calls == [{}] + + +@pytest.mark.asyncio +async def test_the_gate_notice_names_only_tools_the_agent_has() -> None: + model = StubModel() + a = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + a.tool_gate = frozenset({"Echo", "Nope"}) + async for _ in a.run(UserMessage(text="hi")): + pass + told = _user_texts(model.received[-1].messages)[-1] + assert "Tools available now: Echo. Do not call any other tool." in told + assert "Nope" not in told + + +def _compacted(a: Agent) -> None: + """Replace ``a``'s whole history with a summary, as compaction does.""" + a.runtime.append_splice( + mask=full_tape_mask(a.runtime.tape), + insert_after=None, + payload=(UserMessage(text="summary"), AssistantMessage(text="ack")), + strategy="summary", + discards_content=True, + ) + a.runtime.publish(runtime.CompactComplete()) + + +@pytest.mark.asyncio +async def test_a_gate_dropped_by_compaction_is_offered_again() -> None: + """The gate in force is the one the history carries; a summary carries none.""" + model = StubModel() + a = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + a.tool_gate = frozenset({"Echo"}) + async for _ in a.run(UserMessage(text="one")): + pass + _compacted(a) + async for _ in a.run(UserMessage(text="two")): + pass + users = [m for m in model.received[-1].messages if isinstance(m, UserMessage)] + assert users[-1].offered_tools == ("Echo",) + + +def _resume_from(resumed: Agent, first: Agent) -> None: + """Load ``first``'s tape into ``resumed`` under its session, as a resume does.""" + resumed.runtime.session_id = first.runtime.session_id + resumed.runtime.replay_tape(first.runtime.tape) + + +@pytest.mark.asyncio +async def test_a_resumed_agent_lifts_a_gate_its_history_left_up() -> None: + """A fresh process starts ungated; the history it resumes still withholds.""" + first = _build_agent(tools=[StubTool(), StubTool(name="Other")]) + first.tool_gate = frozenset({"Echo"}) + async for _ in first.run(UserMessage(text="one")): + pass + model = StubModel() + resumed = _build_agent(model=model, tools=[StubTool(), StubTool(name="Other")]) + _resume_from(resumed, first) + async for _ in resumed.run(UserMessage(text="two")): + pass + last = model.received[-1].messages[-1] + assert isinstance(last, UserMessage) + assert last.offered_tools == ("Echo", "Other") + assert "All tools are available again." in last.text + + +@pytest.mark.asyncio +async def test_a_resumed_agent_does_not_retell_what_its_history_says() -> None: + first = Agent(model=StubModel(), system="S", prompt_context=_Context()) + first.reminders["rule"] = "Reply with AgentSend." + async for _ in first.run(UserMessage(text="one")): + pass + model = StubModel() + resumed = Agent(model=model, system="S", prompt_context=_Context()) + resumed.reminders["rule"] = "Reply with AgentSend." + resumed.tool_state.bash_cwd = first.tool_state.bash_cwd + _resume_from(resumed, first) + async for _ in resumed.run(UserMessage(text="two")): + pass + told = "".join(_user_texts(model.received[-1].messages)) + assert told.count("Reply with AgentSend.") == 1 + assert told.count("Working directory") == 1 + + +@pytest.mark.asyncio +async def test_a_standing_reminder_is_told_once_and_again_after_compaction() -> None: + """Context a host must keep in force outlives the history that first told it.""" + model = StubModel() + a = Agent(model=model, system="S") + a.reminders["rule"] = "Reply with AgentSend." + for text in ("one", "two"): + async for _ in a.run(UserMessage(text=text)): + pass + assert "".join(_user_texts(model.received[-1].messages)).count("AgentSend") == 1 + _compacted(a) + async for _ in a.run(UserMessage(text="three")): + pass + assert "Reply with AgentSend." in _user_texts(model.received[-1].messages)[-1] + + +@pytest.mark.asyncio +async def test_a_reminder_a_host_withdraws_is_told_withdrawn() -> None: + """Left unsaid, the reminder the context still holds stays in force.""" + model = StubModel() + a = Agent(model=model, system="S") + a.reminders["rule"] = "Reply with AgentSend." + async for _ in a.run(UserMessage(text="one")): + pass + del a.reminders["rule"] + async for _ in a.run(UserMessage(text="two")): + pass + told = _user_texts(model.received[-1].messages)[-1] + assert "No longer in force: Reply with AgentSend." in told + + +@pytest.mark.asyncio +async def test_a_reminder_that_shrinks_to_part_of_an_old_one_is_told() -> None: + """A text inside an older reminder is not one the model was told on its own.""" + first = Agent(model=StubModel(), system="S") + first.reminders["peers"] = "You may message a. You may message b." + async for _ in first.run(UserMessage(text="one")): + pass + model = StubModel() + resumed = Agent(model=model, system="S") + resumed.reminders["peers"] = "You may message a." + _resume_from(resumed, first) + async for _ in resumed.run(UserMessage(text="two")): + pass + assert "You may message a." in _user_texts(model.received[-1].messages)[-1] + + +@dataclass(slots=True, kw_only=True) +class _ArmedCompactor: + """Summarizes the whole history inside the next model call once armed.""" + + armed: bool = False + + @property + def reattach(self) -> ReattachPolicy: + return ReattachPolicy() + + def largest_context(self, settings: AgentSettings) -> int: + return _stub_largest_context(settings) + + def should_compact( + self, + current_tokens: int, + largest_context: int, + system_tokens: int = 0, + ) -> bool: + del current_tokens, largest_context, system_tokens + return self.armed + + async def compact( + self, + tape: Sequence[TapeRecord], + context: Sequence[ModelContextEvent], + model: object, + mint_ref: Callable[[], TapeRef], + custom_instructions: str | None = None, + ) -> ContextSplice: + del context, model, custom_instructions + self.armed = False + return _summary_override([UserMessage(text="summary")], mint_ref, tape=tape) + + +@pytest.mark.asyncio +async def test_a_compaction_inside_a_model_call_keeps_the_standing_context() -> None: + """The summary drops what the call's own round told; its request must not.""" + model = StubModel() + compactor = _ArmedCompactor() + a = Agent( + model=model, + system="S", + prompt_context=_Context(), + tools=[StubTool(), StubTool(name="Other")], + compactor=compactor, + ) + a.tool_gate = frozenset({"Echo"}) + async for _ in a.run(UserMessage(text="one")): + pass + compactor.armed = True + async for _ in a.run(UserMessage(text="two")): + pass + request = model.received[-1] + assert "Working directory" in "".join(_user_texts(request.messages)) + assert ("Echo",) in [ + m.offered_tools for m in request.messages if isinstance(m, UserMessage) + ] + + +@dataclass(slots=True, kw_only=True) +class _RefusingOnceModel(StubModel): + """Refuses its first request (HTTP 200, ``model_refusal``), then answers.""" + + refused: bool = False + + @override + async def stream( + self, + request: ModelRequest, + publish: Callable[[RuntimeEvent], None] | None = None, + ) -> ModelResponse: + if self.refused: + return await super(_RefusingOnceModel, self).stream(request, publish) + self.refused = True + return ModelResponse( + message=AssistantMessage(text=""), + stop_reason="model_refusal", + ) + + +@pytest.mark.asyncio +async def test_a_reminder_withheld_with_a_refused_turn_is_told_again() -> None: + """A refusal masks the turn the reminder rode; the model never kept it.""" + model = _RefusingOnceModel() + a = Agent(model=model, system="S", prompt_context=_Context()) + for text in ("one", "two"): + async for _ in a.run(UserMessage(text=text)): + pass + assert "Working directory" in "".join(_user_texts(model.received[-1].messages)) + + +@pytest.mark.asyncio +async def test_clear_rebuilds_the_system_blocks_from_current_values() -> None: + model = StubModel() + tool = _PromptingTool() + a = Agent(model=model, system="S", tools=[tool], prompt_context=_Context()) + async for _ in a.run(UserMessage(text="one")): + pass + a.system = "S2" + # What a ``Clear`` does: wipe the history, then say so. + _ = a.runtime.append_clear() + a.runtime.publish(runtime.ClearComplete()) + async for _ in a.run(UserMessage(text="two")): + pass + assert len(tool.asked) == 2 + assert model.received[-1].system[0].endswith("S2") + assert "Working directory" in _user_texts(model.received[-1].messages)[-1] + + +@pytest.mark.asyncio +async def test_a_replaced_tool_with_new_guidance_rebuilds_the_system_blocks() -> None: + """A reconfigured tool's prompt reaches the model; an unchanged one costs nothing.""" + model = StubModel() + tool = _PromptingTool(name="One") + a = Agent(model=model, system="S", tools=[tool]) + async for _ in a.run(UserMessage(text="one")): + pass + a.replace_tool("One", _PromptingTool(name="One")) + async for _ in a.run(UserMessage(text="two")): + pass + assert model.received[-1].system == model.received[0].system + a.replace_tool("One", _RewordedTool(name="One")) + async for _ in a.run(UserMessage(text="three")): + pass + assert "use One carefully" in model.received[-1].system[-1] + + +def _user_texts(messages: Sequence[ModelContextEvent]) -> list[str]: + """Return the text of each user-side message, in order.""" + return [m.text for m in messages if isinstance(m, (UserMessage, AgentSendMessage))] @pytest.mark.asyncio @@ -9576,6 +9951,15 @@ def fake_restore( assert a.model_recipe == spec +def test_a_resume_saves_memories_where_its_shown_index_lives(tmp_path: Path) -> None: + """Blocks built before a resume named one directory; Memory saved to another.""" + a = Agent(model=StubModel(), system="S", prompt_context=_Context()) + _ = a.system_blocks() + a.resume(SessionMeta(), [], ToolState(bash_cwd=str(tmp_path))) + _ = a.system_blocks() + assert a.tool_state.project_cwd == str(tmp_path) + + @pytest.mark.asyncio async def test_shutdown_force_false_cancels_detached_and_explicit_bg() -> None: """``shutdown(force=False)`` must cancel detached + explicit_bg tasks. diff --git a/sagent/agent/cache_waste.py b/sagent/agent/cache_waste.py index 982d0f17..1b2cf447 100644 --- a/sagent/agent/cache_waste.py +++ b/sagent/agent/cache_waste.py @@ -10,8 +10,9 @@ prompt, see #361 -- perturbed the prefix bytes). This is detection only, mirroring Pi Agent's ``cache-stats.ts``: it reports -waste after the fact rather than preventing it. Prevention lives in -``tools.agent_spawn``'s hot/cold spawn flag. +waste after the fact rather than preventing it. Prevention lives in the +agent's request layout: system blocks built once per context and shared +with children, with what changes told as appended reminders. """ from __future__ import annotations diff --git a/sagent/agent/context.py b/sagent/agent/context.py index 79c0e526..4f998d75 100644 --- a/sagent/agent/context.py +++ b/sagent/agent/context.py @@ -47,6 +47,7 @@ AgentSendMessage, AssistantMessage, ModelContextEvent, + SystemMessage, ToolResult, UserMessage, wire_role, @@ -186,7 +187,13 @@ def resolve_context(tape: Sequence[TapeRecord]) -> ResolvedContext: if record.ref in masked or not isinstance( event, - (AgentSendMessage, UserMessage, AssistantMessage, ToolResult), + ( + AgentSendMessage, + UserMessage, + AssistantMessage, + ToolResult, + SystemMessage, + ), ) else [event] ) @@ -232,6 +239,8 @@ def validate_context(messages: Sequence[ModelContextEvent]) -> None: 3. No ``ToolResult`` ``call_id`` appears twice. 4. No ``UserMessage`` may appear while tool calls are pending. + A ``SystemMessage`` is no one's turn: every rule skips it. + Args: messages: Provider-facing message sequence to validate. @@ -243,6 +252,8 @@ def validate_context(messages: Sequence[ModelContextEvent]) -> None: seen_results: set[str] = set() prev_role: str | None = None for entry in messages: + if isinstance(entry, SystemMessage): + continue role = wire_role(entry) if role == "assistant": assert isinstance(entry, AssistantMessage) diff --git a/sagent/agent/context_test.py b/sagent/agent/context_test.py index 55ce4cb3..973d5b5d 100644 --- a/sagent/agent/context_test.py +++ b/sagent/agent/context_test.py @@ -26,6 +26,7 @@ AgentSendMessage, AssistantMessage, ModelContextEvent, + SystemMessage, ToolCall, ToolResult, UserMessage, @@ -662,6 +663,33 @@ def test_resolved_context_is_a_resolved_context_instance() -> None: resolve_context([]) +def test_system_messages_are_neutral_to_role_alternation_and_tool_pairing() -> None: + call = ToolCall(id="c1", name="Read", args={}) + validate_context( + [ + UserMessage(text="q"), + SystemMessage(text="cwd: /x"), + AssistantMessage(tool_calls=(call,)), + ToolResult(call_id="c1", content="r"), + SystemMessage(text="Tools available now: Read."), + AssistantMessage(text="done"), + ], + ) + # Neutral, not a turn: the human's lines either side of one still collide. + with pytest.raises(InvalidContextError, match="alternation"): + validate_context( + [UserMessage(text="a"), SystemMessage(text="n"), UserMessage(text="b")], + ) + + +def test_resolve_context_renders_a_system_message() -> None: + note = SystemMessage(text="cwd: /x") + tape: list[TapeRecord] = [ + ReferrableTapeEvent(ref=TapeRef(session_id="s", ordinal=0), event=note), + ] + assert resolve_context(tape).messages == [note] + + if __name__ == "__main__": from sagent.lib.testing.runner import test_main diff --git a/sagent/agent/result_storage.py b/sagent/agent/result_storage.py index e34890f2..6bd4b064 100644 --- a/sagent/agent/result_storage.py +++ b/sagent/agent/result_storage.py @@ -238,7 +238,7 @@ def _format_size(n: int) -> str: # internal cap" -- which became false when that cap was re-expressed in lines, leaving # the 11.1M-character result of session ``190b6baec7ed`` with no bound at all. # -# Error results were too, but ``materialize_request`` elides any over-budget result +# Error results were too, but ``materialize_messages`` elides any over-budget result # regardless, so the exemption did not keep a large traceback whole: it only ensured the # traceback was replaced by a placeholder with no path, while the identical body as a # SUCCESS was written to disk and stayed readable. Exactly backwards, since the failing diff --git a/sagent/agent/runtime.py b/sagent/agent/runtime.py index 70145b63..9f922457 100644 --- a/sagent/agent/runtime.py +++ b/sagent/agent/runtime.py @@ -26,8 +26,10 @@ History ~~~~~~~ -``list[UserMessage | AgentSendMessage | AssistantMessage | ToolResult]`` -(the ``ModelContextEvent`` union). Match on type, not string descriptors. +``list[UserMessage | AgentSendMessage | AssistantMessage | ToolResult | +SystemMessage]`` (the ``ModelContextEvent`` union). Match on type, not string +descriptors. A ``SystemMessage`` is the harness's, no one's turn: alternation, tool +pairing and the model-call gate all look past it. Tool task lifecycle ~~~~~~~~~~~~~~~~~~~ @@ -202,11 +204,13 @@ Composition ~~~~~~~~~~~ -No stubs, no hooks, no callbacks. Extension is via observers on -published events. If something needs to happen outside the runtime -(persist session, swap model, track cost, enforce budget), it's an -event that an observer handles. If you're tempted to add a method -stub or a callback parameter, publish an event instead. +No stubs, no hooks. Extension is via observers on published events. If +something needs to happen outside the runtime (persist session, swap model, +track cost, enforce budget), it's an event that an observer handles. If +you're tempted to add a method stub or a callback parameter, publish an +event instead. The exceptions are the queries the runtime must have answered +inline, mid-decision (``before_tool_spawn``, ``has_pending_background``, +``context_updates``); each says why where it is declared. Why the runtime owns Model and Tool references: the alternative is a generic runtime that only processes events, with the Agent calling @@ -285,6 +289,7 @@ Recompact, RuntimeEvent, SaveSession, + SystemMessage, ToolCall, ToolResult, ToolResultKind, @@ -306,9 +311,11 @@ TapeRef, coalesce_roles, full_tape_mask, + last_turn, mask_contains_ref, mask_ranges_overlap, merge_mask_ranges, + same_sender, splice_safe_repair, unpaired_call_ids, ) @@ -791,6 +798,14 @@ def __init__( # predicate-evaluation time; an async one-directional event cannot # supply that. Same shape and rationale as ``before_tool_spawn``. self.has_pending_background: Callable[[], bool] | None = None + # Asked once per model round, as it is confirmed to fire, for the system messages + # the Agent layer has to add first (what its context no longer says). + # Each is appended as its own ``SystemMessage``, no one's turn, so it can + # never fire a round of its own. Not a pure query: the Agent records + # what it returned, but checks that record against the history each + # round, so a round that then aborts, or a history rewritten under it, + # only means the next answer tells it again. + self.context_updates: Callable[[], Sequence[SystemMessage]] | None = None # Lifted from run_forever locals for observer/REPL visibility. # Task[None]: tools/model post results to inbox, not via return. self.running_tools: dict[str, asyncio.Task[None]] = {} @@ -1534,9 +1549,9 @@ def _defer_pairing(self, result: ToolResult) -> None: """Queue an error ``ToolResult`` pairing an already-emitted ``tool_use``.""" self._pending_commits.append(_PendingCommit(kind="pairing", result=result)) - def _defer_ride_along(self, user: UserMessage) -> None: - """Queue system-injected user-side context to ride the next real turn.""" - self._pending_commits.append(_PendingCommit(kind="ride_along", user=user)) + def _defer_ride_along(self, note: SystemMessage) -> None: + """Queue harness context to ride the next real turn.""" + self._pending_commits.append(_PendingCommit(kind="ride_along", note=note)) # The single drain for all deferred tape commits. Called at the model gate once a # round is confirmed firing (so nothing here wakes the model on its own) and @@ -1549,7 +1564,7 @@ def _defer_ride_along(self, user: UserMessage) -> None: # (``insert_after``), so a mimicked ``tool_use`` pairs in-slot and can never be # stranded by intervening turns. - ``forward``: append the synthetic # ``DetachedArrived`` pair (a user-role separator first when the tail is an - # assistant turn). - ``ride_along``: coalesce onto the user side. + # assistant turn). - ``ride_along``: append the system message. def _flush_pending(self) -> None: """Commit every queued :class:`_PendingCommit`, in arrival order, in-slot.""" pending = self._pending_commits @@ -1564,9 +1579,9 @@ def _flush_pending(self) -> None: raise ValueError("Expected commit.result is not None.") self._append_detached_arrival(commit.result) else: - if commit.user is None: - raise ValueError("Expected commit.user is not None.") - self.publish(self._append_or_coalesce_user(commit.user)) + if commit.note is None: + raise ValueError("Expected commit.note is not None.") + self.append_history(commit.note) # Only ``forward`` (a completed detached tool's real result) wakes the model: a # finished tool is worth surfacing promptly. ``pairing`` / ``ride_along`` are non- @@ -1601,7 +1616,8 @@ def _commit_pairing(self, result: ToolResult) -> None: def _append_detached_arrival(self, result: ToolResult) -> None: """Append the synthetic ``DetachedArrived`` tool pair for ``result``.""" messages = self.context().messages - if messages and isinstance(messages[-1], AssistantMessage): + at = last_turn(messages) + if at is not None and isinstance(messages[at], AssistantMessage): self._append_or_coalesce_user( UserMessage(text=f"[detached tool {result.call_id} completed]"), ) @@ -2142,8 +2158,8 @@ async def run_forever(self) -> None: # running-tools membership now -- otherwise the # cohort never empties, the model-call gate # (``not self.cohort``) never fires, and the deferred - # pairing never flushes (deadlock). A ``UserMessage`` - # payload is ride-along system context. + # pairing never flushes (deadlock). A ``SystemMessage`` + # payload is ride-along harness context. if isinstance(payload, ToolResult): self.cohort.discard(payload.call_id) self.running_tools.pop(payload.call_id, None) @@ -2227,6 +2243,9 @@ async def run_forever(self) -> None: self._flush_pending() if self._ready_to_advance and self._should_call_model(): + if self.context_updates is not None: + for note in self.context_updates(): + self.append_history(note) logger.debug( "runtime model call start: history=%d detached=%d queued=%d", len(self.context().messages), @@ -2374,12 +2393,16 @@ def pending_mid_stream(self) -> Sequence[UserMessage | AgentSendMessage]: """ return tuple(self._mid_stream_queue) + # A system message rides a round that real content drives and never drives one itself, so the + # entry that decides is the last turn before any trailing system messages. def _should_call_model(self) -> bool: """Return True when history ends with content the model should answer.""" messages = self.context().messages - if not messages: - return False - return isinstance(messages[-1], (UserMessage, AgentSendMessage, ToolResult)) + at = last_turn(messages) + return at is not None and isinstance( + messages[at], + (UserMessage, AgentSendMessage, ToolResult), + ) # The model-call gate's last guard before firing the provider: # @@ -2466,6 +2489,9 @@ def _repair_history_record_orphans(self) -> None: hr_orphan_refs: list[TapeRef] = [] for idx, entry in enumerate(messages): + # No one's turn: a system message neither opens nor closes a tool pair. + if isinstance(entry, SystemMessage): + continue origin = origins[idx] origin_record = self._tape_by_ref.get(origin) origin_is_hr = isinstance(origin_record, ReferrableTapeEvent) @@ -2574,37 +2600,32 @@ def _append_or_coalesce_user( r"""Append ``item`` to history; return the committed entry.""" resolved = self.context() messages = resolved.messages - if not messages or wire_role(messages[-1]) != wire_role(item): + # The turn ``item`` meets is the last one before any trailing system messages: a + # round halted after its system message leaves the system message last, and the human's next + # line still joins their unanswered one, with the system message after both. + at = last_turn(messages) + if at is None or wire_role(messages[at]) != wire_role(item): self.append_history(item) return item - tail = messages[-1] + tail = messages[at] assert isinstance(tail, (UserMessage, AgentSendMessage)) - text = f"{tail.text}\n\n{item.text}" - attachments = tail.attachments + item.attachments - # Same-type merges preserve the tail's id (downstream consumers key - # on it). Cross-type merges adopt the agent type when either side - # is an ``AgentSendMessage`` so the source attribution survives; - # the merged entry then gets a fresh id (the prior tail's identity - # is no longer applicable to the changed type). - if isinstance(tail, AgentSendMessage): - combined: UserMessage | AgentSendMessage = dataclasses.replace( - tail, - text=text, - attachments=attachments, - ) - elif isinstance(item, AgentSendMessage): - combined = dataclasses.replace( - item, - text=text, - attachments=attachments, - ) - else: - combined = dataclasses.replace( + # One sender keeps the tail's type and id (downstream consumers key on + # it). Mixed senders take the canonical merge, which labels each part with + # its own sender: one ``[from A]`` over the whole named A as the author of + # the human's words, or of the agent's reminder. + combined = ( + dataclasses.replace( tail, - text=text, - attachments=attachments, + text=f"{tail.text}\n\n{item.text}", + attachments=tail.attachments + item.attachments, + # A part anyone saw keeps the whole visible, as ``coalesce_roles`` + # does: the human's line merged onto a hidden one must still show. + hidden=tail.hidden and item.hidden, ) - tail_origin = resolved.origins[-1] + if same_sender(tail, item) + else _coalesce_user_side((tail, item)) + ) + tail_origin = resolved.origins[at] # When the visible tail is itself a coalesce splice's payload, # the new splice must absorb both that splice AND the original # ref(s) the splice was masking -- otherwise killing the splice @@ -2624,6 +2645,7 @@ def _append_or_coalesce_user( # plain ``session_id`` match selects the right ones. prior_ranges: tuple[MaskRange, ...] = () prior_head: tuple[ModelContextEvent, ...] = () + prior_rest: tuple[ModelContextEvent, ...] = () if isinstance(prior_record, ContextSplice): # EVERY range, not just this session's. Absorbing the prior # splice's ref kills it, and under undelete semantics that lapses @@ -2639,8 +2661,13 @@ def _append_or_coalesce_user( # loses nothing, but a barrier -- resume's ``orphan_tool_result_ # repair``, ``context_rescue``, ``summary`` -- carries the WHOLE # conversation, and dropping it truncated a resumed session to the - # one message that coalesced onto it. - prior_head = prior_record.payload[:-1] + # one message that coalesced onto it. A splice's payload resolves as + # one run, so ``tail``'s place in it is its offset from the run's + # start; what follows it there (the system messages a compaction kept with the + # last turn) is carried after the merge. + offset = at - resolved.origins.index(tail_origin) + prior_head = prior_record.payload[:offset] + prior_rest = prior_record.payload[offset + 1 :] own_range = MaskRange( session_id=sid, lo=tail_origin.ordinal, @@ -2668,7 +2695,7 @@ def _append_or_coalesce_user( if record.ref.ordinal >= lo_ord: break anchor = record.ref - payload = coalesce_roles((*prior_head, combined)) + payload = coalesce_roles((*prior_head, combined, *prior_rest)) self.append_splice( mask=mask, insert_after=anchor, @@ -3337,13 +3364,13 @@ class _PendingCommit: - ``"forward"``: ``result`` is a completed detached tool's real output, delivered as a synthetic ``DetachedArrived`` pair (the original stub stays). A user-role separator precedes it after an assistant tail. - - ``"ride_along"``: ``user`` is system-injected context (e.g. a reminder) - coalesced onto the user side. + - ``"ride_along"``: ``note`` is context the harness tells the model (e.g. a + reminder), appended as its own system message. """ kind: Literal["pairing", "forward", "ride_along"] result: ToolResult | None = None - user: UserMessage | None = None + note: SystemMessage | None = None def _message_from_queued( diff --git a/sagent/agent/runtime_hypothesis_test.py b/sagent/agent/runtime_hypothesis_test.py index 35ade1df..6d15a0d8 100644 --- a/sagent/agent/runtime_hypothesis_test.py +++ b/sagent/agent/runtime_hypothesis_test.py @@ -53,6 +53,7 @@ ToolCall, ToolResult, UserMessage, + WireMessage, wire_role, ) from sagent.types.tape import ( @@ -115,10 +116,10 @@ def _agent_send_with_tool_call(draw: DrawFn) -> AssistantMessage: @composite -def _message_history(draw: DrawFn) -> list[ModelContextEvent]: +def _message_history(draw: DrawFn) -> list[WireMessage]: """Random history mixing user/agent/assistant; tool_results paired by call_id.""" n = draw(integers(min_value=0, max_value=12)) - history: list[ModelContextEvent] = [] + history: list[WireMessage] = [] pending: list[str] = [] # tool_call ids awaiting results. for _ in range(n): choice = draw(sampled_from(["user", "agent", "assistant", "tool_result"])) @@ -144,7 +145,7 @@ def _message_history(draw: DrawFn) -> list[ModelContextEvent]: @settings(max_examples=100, deadline=None) @given(_message_history()) def test_label_agent_sends_prefixes_and_preserves_non_agent_identity( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """Label AgentSends and preserve every non-AgentSend object.""" labelled = list(_label_agent_sends(history)) @@ -165,7 +166,7 @@ def test_label_agent_sends_prefixes_and_preserves_non_agent_identity( @settings(max_examples=200, deadline=None) @given(_message_history()) -def test_coalesce_preserves_each_source(history: list[ModelContextEvent]) -> None: +def test_coalesce_preserves_each_source(history: list[WireMessage]) -> None: """Every source that appears in the input also appears in the output. Catches bug H: pre-fix, adjacent AgentSends from different sources @@ -194,7 +195,7 @@ def test_coalesce_preserves_each_source(history: list[ModelContextEvent]) -> Non @settings(max_examples=200, deadline=None) @given(_message_history()) def test_coalesce_preserves_total_text_length( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: r"""User-side text content is preserved (modulo ``\n\n`` join separators).""" coalesced = _coalesce_adjacent_users(history) @@ -215,7 +216,7 @@ def test_coalesce_preserves_total_text_length( @settings(max_examples=100, deadline=None) @given(_message_history()) def test_coalesce_does_not_reorder_non_user_entries( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """AssistantMessages and ToolResults stay in their original order.""" coalesced = _coalesce_adjacent_users(history) @@ -235,7 +236,7 @@ def test_coalesce_does_not_reorder_non_user_entries( @settings(max_examples=100, deadline=None) @given(_message_history()) -def test_materialize_is_idempotent(history: list[ModelContextEvent]) -> None: +def test_materialize_is_idempotent(history: list[WireMessage]) -> None: """Materializing twice yields the same result as once. Catches the audit's bug6 hardening note: ``_label_agent_sends`` @@ -254,7 +255,7 @@ def test_materialize_is_idempotent(history: list[ModelContextEvent]) -> None: @settings(max_examples=100, deadline=None) @given(_message_history()) def test_materialize_preserves_assistant_count_when_unbudgeted( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """Without a tool-result budget, materialization doesn't drop assistants.""" materialized = materialize_messages(history) @@ -272,7 +273,7 @@ def test_materialize_preserves_assistant_count_when_unbudgeted( @settings(max_examples=200, deadline=None) @given(_message_history()) def test_last_assistant_result_returns_string( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """``_last_assistant_result`` always returns a ``ToolResult`` with a string ``content`` field; no crashes on any history shape. @@ -284,7 +285,7 @@ def test_last_assistant_result_returns_string( @settings(max_examples=100, deadline=None) @given(_message_history()) def test_last_assistant_result_returns_empty_when_no_assistant( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """When history has no ``AssistantMessage``, the result content is ``""``.""" if not any(isinstance(m, AssistantMessage) for m in history): @@ -364,10 +365,10 @@ def _tool_pairing_violation(entries: Sequence[ModelContextEvent]) -> str | None: # produces. Tool results, orphans, and unpaired calls are all still emitted (the rescue # path's job) -- only id-collision is excluded. @composite -def _unique_id_history(draw: DrawFn) -> list[ModelContextEvent]: +def _unique_id_history(draw: DrawFn) -> list[WireMessage]: """Random history with GLOBALLY-UNIQUE tool-call ids, like real tape.""" counter = 0 - history: list[ModelContextEvent] = [] + history: list[WireMessage] = [] pending: list[str] = [] n = draw(integers(min_value=0, max_value=12)) for _ in range(n): @@ -400,10 +401,10 @@ def _unique_id_history(draw: DrawFn) -> list[ModelContextEvent]: # ``_sanitize_for_send`` on this input should produce fully # :func:`validate_context`-clean output, not merely pairing-clean output. @composite -def _alternating_history(draw: DrawFn) -> list[ModelContextEvent]: +def _alternating_history(draw: DrawFn) -> list[WireMessage]: """Random history that never emits two consecutive same-wire-role turns.""" n = draw(integers(min_value=0, max_value=10)) - history: list[ModelContextEvent] = [] + history: list[WireMessage] = [] prev_role: str | None = None counter = 0 for _ in range(n): @@ -441,7 +442,7 @@ def _alternating_history(draw: DrawFn) -> list[ModelContextEvent]: @settings(max_examples=300, deadline=None) @given(_unique_id_history()) def test_sanitize_output_satisfies_tool_pairing( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """``_sanitize_for_send`` output is tool-pairing-correct for ANY input. @@ -460,7 +461,7 @@ def test_sanitize_output_satisfies_tool_pairing( @settings(max_examples=300, deadline=None) @given(_message_history()) def test_splice_safe_repair_output_always_constructs( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """``splice_safe_repair`` output is always a valid ``ContextSplice`` payload. @@ -482,7 +483,7 @@ def test_splice_safe_repair_output_always_constructs( @settings(max_examples=300, deadline=None) @given(_unique_id_history()) -def test_sanitize_is_idempotent(history: list[ModelContextEvent]) -> None: +def test_sanitize_is_idempotent(history: list[WireMessage]) -> None: """Sanitizing twice equals sanitizing once. A repair pass that is not a fixed point would keep mutating already-valid @@ -497,7 +498,7 @@ def test_sanitize_is_idempotent(history: list[ModelContextEvent]) -> None: @settings(max_examples=300, deadline=None) @given(_unique_id_history()) def test_sanitize_never_invents_call_ids( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """Every ToolResult in the output answers a call id present in the input. @@ -519,7 +520,7 @@ def test_sanitize_never_invents_call_ids( @settings(max_examples=300, deadline=None) @given(_alternating_history()) def test_sanitize_of_alternating_history_fully_validates( - history: list[ModelContextEvent], + history: list[WireMessage], ) -> None: """On wire-alternation-respecting input, output passes ``validate_context``. diff --git a/sagent/agent/runtime_test.py b/sagent/agent/runtime_test.py index 4336528e..efc56c29 100644 --- a/sagent/agent/runtime_test.py +++ b/sagent/agent/runtime_test.py @@ -63,6 +63,7 @@ Quit, Recompact, RuntimeEvent, + SystemMessage, ToolCall, ToolResult, ToolResultKind, @@ -80,6 +81,7 @@ ReferrableTapeEvent, TapeRecord, TapeRef, + full_tape_mask, mask_contains_ref, ) @@ -280,9 +282,7 @@ def _assistant_texts(agent: runtime.AgentRuntime) -> list[str]: async def test_lazy_event_does_not_trigger_a_round_alone() -> None: """A ``LazyEvent`` alone fires no model round; it waits for a real turn.""" agent, _ = make_agent([AssistantMessage(text="should not fire")]) - agent.inbox.push_back( - LazyEvent(payload=UserMessage(text="x")), - ) + agent.inbox.push_back(LazyEvent(payload=SystemMessage(text="x"))) agent.inbox.push_back(Quit()) await run_until_quit(agent, timeout_sec=2.0) @@ -295,27 +295,18 @@ async def test_lazy_event_does_not_trigger_a_round_alone() -> None: @pytest.mark.asyncio -async def test_lazy_event_rides_next_real_turn() -> None: - """A pending ``LazyEvent`` payload commits alongside the next real event.""" +async def test_lazy_event_rides_next_real_turn_as_the_harnesss_not_the_humans() -> None: + """A deferred reminder rides the next turn and never becomes the human's words.""" agent, _ = make_agent([AssistantMessage(text="answer")]) - agent.inbox.push_back( - LazyEvent(payload=UserMessage(text="nudge")), - ) + agent.inbox.push_back(LazyEvent(payload=SystemMessage(text="nudge"))) agent.inbox.push_back(UserMessage(text="hello")) await run_with_quit(agent, timeout_sec=2.0) - # The reminder rode the real turn: its text is in context (possibly - # coalesced with the user message) and the model ran once. - all_user_text = " ".join( - m.text for m in agent.context().messages if isinstance(m, UserMessage) - ) - assert "nudge" in all_user_text - assert "hello" in all_user_text - assert any( - isinstance(m, AssistantMessage) and m.text == "answer" - for m in agent.context().messages - ) + history = agent.context().messages + assert [m.text for m in history if isinstance(m, UserMessage)] == ["hello"] + assert [m.text for m in history if isinstance(m, SystemMessage)] == ["nudge"] + assert any(isinstance(m, AssistantMessage) and m.text == "answer" for m in history) assert not agent._pending_commits @@ -339,8 +330,9 @@ async def test_agent_send_queued_message_coalesces_with_user_message() -> None: """AgentSend + User adjacents merge into one user-role wire entry. Both serialize as user role on the wire; back-to-back user-role - turns violate Anthropic-style alternation. The merged entry adopts - the agent type so the ``source`` attribution survives. + turns violate Anthropic-style alternation. Each part keeps its own + sender: a ``source`` on the merge would name the reviewer as the + author of the human's reply. """ agent, collector = make_agent([AssistantMessage(text="reply")]) agent.inbox.push_back(AgentSendQueuedMessage(source="reviewer", text="finding")) @@ -350,13 +342,11 @@ async def test_agent_send_queued_message_coalesces_with_user_message() -> None: messages = agent.context().messages assert [type(message) for message in messages[:2]] == [ - AgentSendMessage, + UserMessage, AssistantMessage, ] - assert isinstance(messages[0], AgentSendMessage) - assert messages[0].source == "reviewer" - assert "finding" in messages[0].text - assert "my reply" in messages[0].text + assert isinstance(messages[0], UserMessage) + assert messages[0].text == "[from reviewer]: finding\n\nmy reply" assert collector.has(ModelIdle) @@ -392,18 +382,16 @@ def _quit_on_second_idle(event: RuntimeEvent) -> None: messages = agent.context().messages # ``later`` (UserDeferredMessage) and ``agent later`` # (AgentSendDeferredMessage) coalesce into a single wire user-role - # turn; the merged entry adopts the agent type to preserve source. + # turn; each part keeps its own sender. assert [type(message) for message in messages] == [ UserMessage, AssistantMessage, - AgentSendMessage, + UserMessage, AssistantMessage, ] merged = messages[2] - assert isinstance(merged, AgentSendMessage) - assert merged.source == "reviewer" - assert "later" in merged.text - assert "agent later" in merged.text + assert isinstance(merged, UserMessage) + assert merged.text == "later\n\n[from reviewer]: agent later" assert collector.has(ModelIdle) @@ -5247,6 +5235,73 @@ def test_coalescing_no_user_side_entry_is_refused_by_name(self) -> None: with pytest.raises(ValueError, match=r"^Expected len\(merged\) == 1\.$"): _ = runtime._coalesce_user_side([]) + def test_a_visible_message_merged_onto_a_hidden_tail_stays_visible(self) -> None: + """The human's line onto an agent's reminder: what they typed still shows.""" + agent = _runtime_for_alternation_tests() + agent.append_history(UserMessage(text="", hidden=True)) + typed = agent._append_or_coalesce_user(UserMessage(text="hi")) + assert typed.hidden is False + reminder = agent._append_or_coalesce_user( + UserMessage(text="", hidden=True), + ) + assert reminder.hidden is False + + @pytest.mark.parametrize("peer_first", [True, False]) + def test_a_merge_across_senders_keeps_each_part_attributed( + self, + *, + peer_first: bool, + ) -> None: + """One ``[from A]`` label over the whole merge put A's name on the human's words.""" + agent = _runtime_for_alternation_tests() + peer = AgentSendMessage(source="A", text="a") + human = UserMessage(text="u") + first, second = (peer, human) if peer_first else (human, peer) + agent.append_history(first) + merged = agent._append_or_coalesce_user(second) + assert isinstance(merged, UserMessage) + assert merged.text == ("[from A]: a\n\nu" if peer_first else "u\n\n[from A]: a") + assert agent.context().messages == [merged] + + def test_a_line_after_a_system_message_joins_the_turn_the_note_follows( + self, + ) -> None: + """A halted round leaves its system message last; the human's next line is still one turn.""" + agent = _runtime_for_alternation_tests() + agent.append_history(UserMessage(text="first")) + note = SystemMessage(text="cwd: /x") + agent.append_history(note) + merged = agent._append_or_coalesce_user(UserMessage(text="second")) + assert agent.context().messages == [merged, note] + assert isinstance(merged, UserMessage) + assert merged.text == "first\n\nsecond" + + def test_a_line_joins_a_turn_whose_note_rides_in_a_splice(self) -> None: + """A compaction keeps the tail turn and its system message in one payload.""" + agent = _runtime_for_alternation_tests() + agent.append_history(UserMessage(text="old")) + note = SystemMessage(text="cwd: /x") + agent.append_splice( + mask=full_tape_mask(agent.tape), + payload=(UserMessage(text="summary\n\nfirst"), note), + strategy="summary", + discards_content=True, + ) + merged = agent._append_or_coalesce_user(UserMessage(text="second")) + assert agent.context().messages == [merged, note] + assert isinstance(merged, UserMessage) + assert merged.text == "summary\n\nfirst\n\nsecond" + + def test_a_system_message_alone_does_not_call_the_model(self) -> None: + """It rides a round real content drives; after an answer it drives none.""" + agent = _runtime_for_alternation_tests() + agent.append_history(UserMessage(text="q")) + agent.append_history(SystemMessage(text="n")) + assert agent._should_call_model() + agent.append_history(AssistantMessage(text="a")) + agent.append_history(SystemMessage(text="n2")) + assert not agent._should_call_model() + def test_append_or_coalesce_user_merges_cross_type(self) -> None: """UserMessage tail + AgentSend incoming merge into a single user turn.""" agent = _runtime_for_alternation_tests() diff --git a/sagent/agent/session_io.py b/sagent/agent/session_io.py index ebd305f1..3b631784 100644 --- a/sagent/agent/session_io.py +++ b/sagent/agent/session_io.py @@ -76,7 +76,9 @@ RuntimeEvent, SaveSession, ServiceErrorSnapshot, + SessionMessage, StatusChanged, + SystemMessage, ToolCall, ToolResult, ToolResultKind, @@ -138,9 +140,10 @@ class PersistentAgentRecord: service_tier: str = "auto" max_budget_usd: float | None = None persistent_retry: bool = False + append_system: str = "" frozen_system: bool = False - """A hot child's ``system`` is already the parent's rendered prompt; - resuming it cold would re-append its own tools' contributions.""" + """Read, never written: a hot spawn (gone now) saved its parent's whole rendered + prompt as ``system``, so a rebuild would repeat every block already inside it.""" def serialize_tool_state(state: ToolState) -> dict[str, object]: @@ -478,7 +481,7 @@ def append_persistent_agent_lifecycle( account=spec.account if spec else None, model_id=child.model.tagged_model_id, tools=tuple(tool.name for tool in child.tools), - system=child.base_system_spec, + system=child.system, notify_on_asleep=notify_on_asleep, max_tool_call_rounds=child.max_tool_call_rounds, max_request_tokens=child.settings.max_request_tokens, @@ -491,7 +494,7 @@ def append_persistent_agent_lifecycle( service_tier=settings.service_tier, max_budget_usd=child.max_budget_usd, persistent_retry=child.persistent_retry, - frozen_system=child.frozen_system, + append_system=child.append_system, ), ], ) @@ -1105,6 +1108,17 @@ def _entry_to_json(entry: TapeEvent) -> dict[str, object]: "parent_id": entry.parent_id, "timestamp": entry.timestamp, "hidden": entry.hidden, + "cache_point": entry.cache_point, + } + if isinstance(entry, SystemMessage): + return { + "type": "system_message", + "text": entry.text, + "offered_tools": _offered_to_json(entry.offered_tools), + "id": entry.id, + "parent_id": entry.parent_id, + "timestamp": entry.timestamp, + "hidden": entry.hidden, } if isinstance(entry, AgentSendMessage): return { @@ -1158,6 +1172,25 @@ def _entry_to_json(entry: TapeEvent) -> dict[str, object]: } +def _offered_to_json(offered: tuple[str, ...] | None) -> list[str] | None: + """Encode a system message's tool offer; ``None`` (no change) stays ``null``.""" + return None if offered is None else list(offered) + + +# ``null`` and an absent key both mean the system message changed no offer. A malformed offer is +# dropped like a malformed tool call: the system message still loads, and the agent offers its +# gate again on the next round because the history no longer holds it. +def _offered_from_json(raw: object) -> tuple[str, ...] | None: + """Decode a system message's tool offer.""" + if raw is None: + return None + try: + return tuple(from_plain(raw, list[str])) + except ReadError: + logger.warning("Dropping malformed offered_tools: %r", raw) + return None + + def _ref_to_json(ref: TapeRef) -> dict[str, object]: """Encode a ``TapeRef`` as ``{session_id, ordinal}``.""" return {"session_id": ref.session_id, "ordinal": ref.ordinal} @@ -1265,23 +1298,6 @@ def _splice_from_json( mask = _mask_from_json(rec.get("mask")) raw_insert = rec.get("insert_after") insert_after = _ref_from_json(raw_insert) if raw_insert is not None else None - raw_payload = rec.get("payload") - payload: list[ModelContextEvent] = [] - if isinstance(raw_payload, list): - for item in cast(list[object], raw_payload): - if isinstance(item, dict): - entry = _entry_from_json(cast(Mapping[str, object], item)) - if isinstance( - entry, - (AgentSendMessage, UserMessage, AssistantMessage, ToolResult), - ): - payload.append(entry) - raw_paired = rec.get("paired_externally") - paired: frozenset[str] = frozenset[str]() - if isinstance(raw_paired, list): - paired = frozenset( - item for item in cast(list[object], raw_paired) if isinstance(item, str) - ) # ``replay()`` skips both mask-disjointness and payload-pairing # validation; legacy sessions converted to splice format may carry # masks the validator would reject. @@ -1289,16 +1305,42 @@ def _splice_from_json( ref=ref, mask=mask, insert_after=insert_after, - payload=tuple(payload), + payload=_payload_from_json(rec.get("payload")), strategy=str(rec.get("strategy") or ""), token_before=_read(rec.get("token_before"), int, 0), token_after=_read(rec.get("token_after"), int, 0), fallback_reason=str(rec.get("fallback_reason") or ""), preserved_tail_count=_read(rec.get("preserved_tail_count"), int, 0), - paired_externally=paired, + paired_externally=_paired_from_json(rec.get("paired_externally")), + ) + + +# A payload holds history entries only, the system messages among them: dropping those +# resumed a context without the reminders and the tool offer the live one held. +def _payload_from_json(raw: object) -> tuple[ModelContextEvent, ...]: + """Decode a splice payload's history entries.""" + if not isinstance(raw, list): + return () + entries = ( + _entry_from_json(cast(Mapping[str, object], item)) + for item in cast(list[object], raw) + if isinstance(item, dict) + ) + return tuple( + entry + for entry in entries + if entry is not None + and not isinstance(entry, CompactStarted | CompactComplete | CompactFailed) ) +def _paired_from_json(raw: object) -> frozenset[str]: + """Decode a splice's externally paired call ids.""" + if not isinstance(raw, list): + return frozenset[str]() + return frozenset(item for item in cast(list[object], raw) if isinstance(item, str)) + + # The conversion: - ``suppresses`` set → one mask range per contiguous ordinal run. - # ``inject_after`` → ``insert_after``. - ``barrier=True`` with empty ``suppresses`` → # mask range from tape head up to (and including) ``inject_after``, or empty mask if @@ -1333,34 +1375,17 @@ def _legacy_override_to_splice( mask = (MaskRange(session_id=ref.session_id, lo=0, hi=max(0, ref.ordinal - 1)),) else: mask = () - raw_payload = rec.get("payload") - payload: list[ModelContextEvent] = [] - if isinstance(raw_payload, list): - for item in cast(list[object], raw_payload): - if isinstance(item, dict): - entry = _entry_from_json(cast(Mapping[str, object], item)) - if isinstance( - entry, - (AgentSendMessage, UserMessage, AssistantMessage, ToolResult), - ): - payload.append(entry) - raw_paired = rec.get("paired_externally") - paired: frozenset[str] = frozenset[str]() - if isinstance(raw_paired, list): - paired = frozenset( - item for item in cast(list[object], raw_paired) if isinstance(item, str) - ) return ContextSplice.replay( ref=ref, mask=mask, insert_after=inject_after, - payload=tuple(payload), + payload=_payload_from_json(rec.get("payload")), strategy=str(rec.get("strategy") or ""), token_before=_read(rec.get("token_before"), int, 0), token_after=_read(rec.get("token_after"), int, 0), fallback_reason=str(rec.get("fallback_reason") or ""), preserved_tail_count=_read(rec.get("preserved_tail_count"), int, 0), - paired_externally=paired, + paired_externally=_paired_from_json(rec.get("paired_externally")), ) @@ -1436,6 +1461,16 @@ def _entry_from_json(d: Mapping[str, object]) -> TapeEvent | None: parent_id=parent_id, timestamp=timestamp, hidden=hidden, + cache_point=_json_bool(d.get("cache_point")), + ) + if t == "system_message": + return SystemMessage( + text=str(d.get("text") or ""), + offered_tools=_offered_from_json(d.get("offered_tools")), + id=entry_id, + parent_id=parent_id, + timestamp=timestamp, + hidden=hidden, ) if t == "agent_send": return AgentSendMessage( @@ -1678,6 +1713,7 @@ def _persistent_agent_from_json( service_tier=str(record.get("service_tier") or "auto"), max_budget_usd=_optional_float(record.get("max_budget_usd")), persistent_retry=_json_bool(record.get("persistent_retry")), + append_system=str(record.get("append_system") or ""), frozen_system=_json_bool(record.get("frozen_system")), ) @@ -1796,7 +1832,7 @@ def _persistent_agent_to_json(record: PersistentAgentRecord) -> dict[str, object "service_tier": record.service_tier, "max_budget_usd": record.max_budget_usd, "persistent_retry": record.persistent_retry, - "frozen_system": record.frozen_system, + "append_system": record.append_system, "timestamp": time.time(), } @@ -2080,10 +2116,7 @@ def _seed_id_counter(tape: Sequence[TapeRecord]) -> None: for record in tape: if isinstance(record, ReferrableTapeEvent): event = record.event - if isinstance( - event, - (AgentSendMessage, UserMessage, AssistantMessage, ToolResult), - ): + if isinstance(event, SessionMessage): max_id = max(max_id, event.id) else: for entry in record.payload: diff --git a/sagent/agent/session_io_test.py b/sagent/agent/session_io_test.py index 22104801..cb5cccf6 100644 --- a/sagent/agent/session_io_test.py +++ b/sagent/agent/session_io_test.py @@ -63,6 +63,7 @@ RuntimeEvent, SaveSession, ServiceErrorSnapshot, + SystemMessage, ToolCall, ToolResult, ToolResultKind, @@ -196,6 +197,89 @@ def test_hidden_flag_round_trips(tmp_path: Path) -> None: assert hidden.hidden is True +def test_a_cache_point_round_trips(tmp_path: Path) -> None: + """A resumed request marks the caches the original did.""" + user = _round_trip(UserMessage(text="doc", cache_point=True), tmp_path / "a") + assert isinstance(user, UserMessage) + assert user.cache_point is True + plain = _round_trip(UserMessage(text="plain"), tmp_path / "b") + assert isinstance(plain, UserMessage) + assert plain.cache_point is False + + +def test_a_system_message_round_trips_with_its_tool_offer(tmp_path: Path) -> None: + """A resumed request offers the tools the original did.""" + note = _round_trip( + SystemMessage(text="Tools available now: Read.", offered_tools=("Read",)), + tmp_path / "a", + ) + assert isinstance(note, SystemMessage) + assert (note.text, note.offered_tools) == ("Tools available now: Read.", ("Read",)) + plain = _round_trip(SystemMessage(text="cwd: /x"), tmp_path / "b") + assert isinstance(plain, SystemMessage) + assert plain.offered_tools is None + + +def test_a_malformed_tool_offer_still_loads_its_note() -> None: + """The agent offers its gate again when the history holds none.""" + entry = _entry_from_json( + {"type": "system_message", "text": "x", "offered_tools": [1]}, + ) + assert isinstance(entry, SystemMessage) + assert (entry.text, entry.offered_tools) == ("x", None) + + +def _saved(directory: Path, *events: ModelContextEvent) -> list[TapeRecord]: + """Return ``events`` as one session's tape, saved to ``directory``.""" + tape: list[TapeRecord] = [ + ReferrableTapeEvent(ref=TapeRef(session_id="s", ordinal=i), event=event) + for i, event in enumerate(events) + ] + append_session( + directory / "session.jsonl", + meta=SessionMeta(session_id="s", model_id="m").serialize(), + tape_delta=tape, + ) + return tape + + +def test_a_system_message_a_compaction_kept_survives_a_reload(tmp_path: Path) -> None: + """The resumed context holds the reminders and the offer the live one did.""" + note = SystemMessage(text="Working directory: /w", offered_tools=("Read",)) + [old] = _saved(tmp_path, UserMessage(text="old")) + append_session( + tmp_path / "session.jsonl", + tape_delta=[ + ContextSplice( + ref=TapeRef(session_id="s", ordinal=1), + mask=(MaskRange.between(old.ref, old.ref),), + insert_after=None, + payload=(UserMessage(text="summary"), note), + strategy="summary", + ), + ], + ) + + loaded = load_session(tmp_path) + + assert loaded is not None + *_, kept = resolve_context(loaded[1]).messages + assert isinstance(kept, SystemMessage) + assert (kept.text, kept.offered_tools) == (note.text, note.offered_tools) + + +def test_a_resume_mints_ids_past_its_system_messages(tmp_path: Path) -> None: + """An id minted twice gave two messages one Anthropic cache mark between them.""" + user = UserMessage(text="go") + # As a fresh process sees it: the saved ids are past every id minted here. + note = SystemMessage(id=user.id + 1_000_000, text="Working directory: /w") + _saved(tmp_path, user, note) + + assert load_session(tmp_path) is not None + + assert UserMessage(text="next").id > note.id + + def test_agent_send_message_round_trip(tmp_path: Path) -> None: out = _round_trip(AgentSendMessage(source="reviewer", text="finding"), tmp_path) assert isinstance(out, AgentSendMessage) @@ -2229,16 +2313,16 @@ def test_append_session_writes_persistent_agent_lifecycle(tmp_path: Path) -> Non "service_tier": "auto", "max_budget_usd": None, "persistent_retry": False, - "frozen_system": False, + "append_system": "", "timestamp": record["timestamp"], } -def test_persistent_agent_record_round_trips_frozen_system(tmp_path: Path) -> None: - """A hot child's frozen flag must survive the parent's session.jsonl.""" +def test_persistent_agent_record_round_trips_append_system(tmp_path: Path) -> None: + """A child's appended instructions must survive the parent's session.jsonl.""" session_file = tmp_path / "session.jsonl" - hot = PersistentAgentRecord( - label="hot-child", + child = PersistentAgentRecord( + label="child", run_id="run-1", session_dir=str(tmp_path / "children" / "run-1"), state="running", @@ -2249,10 +2333,10 @@ def test_persistent_agent_record_round_trips_frozen_system(tmp_path: Path) -> No tools=("Read",), system="rendered parent prompt", notify_on_asleep=True, - frozen_system=True, + append_system="Be brief.", ) - append_session(session_file, persistent_agents=[hot]) - assert load_persistent_agents(tmp_path) == [hot] + append_session(session_file, persistent_agents=[child]) + assert load_persistent_agents(tmp_path) == [child] def test_load_persistent_agents_returns_latest_running_records(tmp_path: Path) -> None: diff --git a/sagent/agent/state.py b/sagent/agent/state.py index 0657c98a..d526ef4b 100644 --- a/sagent/agent/state.py +++ b/sagent/agent/state.py @@ -93,6 +93,10 @@ class ToolState: """Current bash working directory (mutated by ``cd``). Defaults to ``start_cwd`` when left empty at construction.""" + project_cwd: str = "" + """The directory whose AGENTS.md and memory index the agent's system blocks + show; set when they are built. Empty before the first request.""" + additional_dirs: list[str] = dataclasses.field(default_factory=list) """Extra dirs whose ``AGENTS.md`` files are walked.""" @@ -678,16 +682,16 @@ class PersistableAgent(Protocol): def activity(self) -> _ActivityLike: ... # noqa: D102 -- Protocol member declaration. @property - def base_system_spec(self) -> str: ... # noqa: D102 -- Protocol member declaration. + def system(self) -> str: ... # noqa: D102 -- Protocol member declaration. @property - def compaction_state(self) -> _CompactionStateLike: ... # noqa: D102 -- Protocol member declaration. + def append_system(self) -> str: ... # noqa: D102 -- Protocol member declaration. @property - def cost_tracker(self) -> CostTracker: ... # noqa: D102 -- Protocol member declaration. + def compaction_state(self) -> _CompactionStateLike: ... # noqa: D102 -- Protocol member declaration. @property - def frozen_system(self) -> bool: ... # noqa: D102 -- Protocol member declaration. + def cost_tracker(self) -> CostTracker: ... # noqa: D102 -- Protocol member declaration. @property def max_budget_usd(self) -> float | None: ... # noqa: D102 -- Protocol member declaration. diff --git a/sagent/assets/bare.yaml b/sagent/assets/bare.yaml index 3a16e71c..e8e91f40 100644 --- a/sagent/assets/bare.yaml +++ b/sagent/assets/bare.yaml @@ -1,5 +1,6 @@ -# Bare prompt recipe: only ``--system`` text is sent. -# No static prompt, no environment, no AGENTS.md walk, no memory. +# Bare prompt recipe: only ``--system-prompt`` / ``--append-system-prompt`` text and +# the tools' own prompts are sent. No static prompt, no environment, no AGENTS.md +# walk, no memory: the one listed section names none of them, so none is enabled. # Use for benchmarks / minimal-input runs. system_prompt: diff --git a/sagent/assets/default/prompt.md b/sagent/assets/default/prompt.md index 9660b5ca..e9543816 100644 --- a/sagent/assets/default/prompt.md +++ b/sagent/assets/default/prompt.md @@ -105,4 +105,4 @@ A user correction is data that your model of the task was wrong -- update to it # Detached tool results -When the user interrupts mid-tool-call, in-flight tool calls receive a placeholder result reading `[detached: tool still running; real result arrives in a later message]` so the API contract (every tool call needs a tool response) is satisfied. The tool is **still running** -- the real result splices into that slot in a later message, with no further user input required. Do not retry the call; do not treat the placeholder as failure or cancellation. +When the user interrupts mid-tool-call, in-flight tool calls receive a placeholder result reading `[detached: tool still running; real result arrives in a later message]` so the API contract (every tool call needs a tool response) is satisfied. The tool is **still running** -- its result arrives later as a new `DetachedArrived` tool turn, with no further user input required. Do not retry the call; do not treat the placeholder as failure or cancellation. diff --git a/sagent/assets/default/prompt_env.md b/sagent/assets/default/prompt_env.md index ed17caba..24489f29 100644 --- a/sagent/assets/default/prompt_env.md +++ b/sagent/assets/default/prompt_env.md @@ -5,3 +5,4 @@ Platform: {platform} Shell: {shell_line} OS: {os_version} Model: {model_id}.{knowledge_cutoff_line} +Current time: {now}. diff --git a/sagent/assets/default/tools_agentspawn.md b/sagent/assets/default/tools_agentspawn.md index b9200779..73f1a2f6 100644 --- a/sagent/assets/default/tools_agentspawn.md +++ b/sagent/assets/default/tools_agentspawn.md @@ -7,7 +7,6 @@ Before spawning, decide: - **Inputs** -- which files / sections the child may read. - **Return form** -- bullets, JSON, citations, etc. - **Non-goals** -- what the child must NOT do (edit files, spawn further, etc.). -- **`hot`** -- set true. Omit/false only if this child must know its own live depth/tool state. A spawn prompt without the first four is under-specified and the child will drift. @@ -28,6 +27,5 @@ Recycle before spawning fresh. Every spawn reloads a full system prompt (expensi - `persistent` -- long-running background agent. Drive via `AgentSend`; plain assistant text is invisible unless it `AgentSend`s back or `notify_on_asleep` fires. - `notify_on_asleep` (persistent, default true) -- edge-triggered idle ping: `[