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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ctl/src/mas/ctl/cli/commands/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@
@click.option("--load-checkpoint", default=None, type=click.Path())
@click.option("--save-checkpoint/--no-save-checkpoint", default=False)
@click.option("--no-validate", is_flag=True, help="Skip schema validation for seeds/checkpoints")
@click.option(
"--cache-read/--no-cache-read",
default=None,
help="Look up a cached response before calling the LLM "
"(default: spec.execution.cache.read / MAS_LLM_CACHE_READ / true)",
)
@click.option(
"--cache-write/--no-cache-write",
default=None,
help="Persist a response to the cache after calling the LLM "
"(default: spec.execution.cache.write / MAS_LLM_CACHE_WRITE / true)",
)
@click.option(
"--without-obs",
is_flag=True,
Expand Down Expand Up @@ -97,6 +109,8 @@ def chat_cmd(
load_checkpoint: str | None,
save_checkpoint: bool,
no_validate: bool,
cache_read: bool | None,
cache_write: bool | None,
without_obs: bool,
without_gov: bool,
events: bool | None,
Expand Down Expand Up @@ -195,6 +209,8 @@ def _opt_dir(path: str | None) -> Path | None:
checkpoint_path=_opt_file(load_checkpoint),
checkpoint_dir=_opt_dir(checkpoint_dir),
validate_manifests=not no_validate,
cache_read_override=cache_read,
cache_write_override=cache_write,
agent_manifest=agent_data,
manifest_dir=session.manifest_dir if manifest else None,
resolved_infra=resolve_session_infra(
Expand Down
4 changes: 3 additions & 1 deletion ctl/src/mas/ctl/manifest/spec_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ def parse_execution(raw: Any) -> None:
_reject_unknown_keys(mocking, allowed=frozenset({"enabled"}), field="spec.execution.mocking")
cache = raw.get("cache")
if isinstance(cache, dict):
_reject_unknown_keys(cache, allowed=frozenset({"enabled"}), field="spec.execution.cache")
_reject_unknown_keys(
cache, allowed=frozenset({"enabled", "read", "write"}), field="spec.execution.cache"
)


def parse_control(raw: Any) -> None:
Expand Down
4 changes: 4 additions & 0 deletions ctl/src/mas/ctl/session/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class InstantiationOptions:
enable_coordination: bool = True
hitl_contract: object | None = None
user_io_contract: object | None = None
cache_read_override: bool | None = None
cache_write_override: bool | None = None


def instantiate_runtime(
Expand Down Expand Up @@ -144,6 +146,8 @@ def instantiate_runtime(
anchor=options.manifest_dir or Path.cwd(),
workspace=ws,
kernel_config=_kernel_cfg,
cache_read_override=options.cache_read_override,
cache_write_override=options.cache_write_override,
)
logger.info("Engine mode=%s (%s)", selection.mode, selection.reason)

Expand Down
59 changes: 55 additions & 4 deletions ctl/src/mas/ctl/session/engine_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from mas.ctl.infra.resolve import api_key_for_infra
from mas.ctl.session.manifest_config import engine_use_tool_loop, kernel_config_from_manifest # kernel_config_from_manifest: deprecated; prefer RuntimeInstance.from_spec()
from mas.ctl.workspace.config import UserConfig, WorkspaceConfig, collect_mas_infra_refs, merge_infra_refs
from mas.runtime.engine.llm_cache import resolve_cache_path
from mas.runtime.engine.llm_live import LiveLlmEngine
from mas.runtime.agent_defaults import default_pattern_plugin_id, resolve_default_model
from mas.runtime.driver.mocks import AutoCtxAssembler
Expand Down Expand Up @@ -125,6 +126,8 @@ def build_engine(
anchor: Path | None = None,
workspace: WorkspaceConfig | None = None,
kernel_config: KernelConfig | None = None,
cache_read_override: bool | None = None,
cache_write_override: bool | None = None,
) -> EngineSelection:
pid = pattern_plugin_id or default_pattern_plugin_id()
# Use pre-parsed kernel config if provided (spec-aware path); fall back to manifest parsing.
Expand Down Expand Up @@ -163,7 +166,10 @@ def build_engine(

model = resolve_model_name(manifest, resolved, workspace_default=workspace_default_model)
cache_raw = llm_proxy.get("cache_path")
cache_path = Path(str(cache_raw)) if cache_raw else None
cache_read = _cache_read_enabled(manifest, override=cache_read_override)
cache_write = _cache_write_enabled(manifest, override=cache_write_override)
cache_active = (cache_read or cache_write) and not mock and not (llm_proxy.get("pipeline"))
cache_path = Path(str(cache_raw)) if cache_raw else resolve_cache_path() if cache_active else None

engine = _wrap_with_infra_pipeline(
LiveLlmEngine(
Expand All @@ -175,7 +181,9 @@ def build_engine(
temperature=float(llm_spec.get("temperature", 0.7)),
max_tokens=int(llm_spec.get("max_tokens", 2000)),
cache_path=cache_path,
use_cache=_use_cache(manifest) and not mock and not (llm_proxy.get("pipeline")),
use_cache=cache_active,
cache_read=cache_read,
cache_write=cache_write,
use_tool_loop=tool_loop,
parallel_tool_calls=kernel_cfg.parallel_tool_calls,
llm_proxy=llm_proxy,
Expand All @@ -201,12 +209,55 @@ def build_engine(
return EngineSelection(engine=engine, mode=mode, reason=reason)


def _use_cache(manifest: dict | None) -> bool:
def _bool_env(name: str) -> bool | None:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return None
return raw.strip().lower() in ("1", "true", "yes", "on")


def _cache_settings(manifest: dict | None) -> dict[str, Any]:
spec = (manifest or {}).get("spec") or {}
execution = spec.get("execution") or {}
cache = execution.get("cache") or {}
return execution.get("cache") or {}


def _cache_read_enabled(manifest: dict | None, *, override: bool | None = None) -> bool:
"""Whether to look up a cached response before calling the LLM.

Precedence (highest first): explicit CLI override -> spec.execution.cache.
enabled: false (a hard kill-switch for both read and write) -> spec.
execution.cache.read -> MAS_LLM_CACHE_READ env var -> default true.
"""
if override is not None:
return override
cache = _cache_settings(manifest)
if cache.get("enabled") is False:
return False
if isinstance(cache.get("read"), bool):
return cache["read"]
env = _bool_env("MAS_LLM_CACHE_READ")
if env is not None:
return env
return True


def _cache_write_enabled(manifest: dict | None, *, override: bool | None = None) -> bool:
"""Whether to persist a response to the cache after calling the LLM.

Same precedence as _cache_read_enabled, mirrored for spec.execution.
cache.write / MAS_LLM_CACHE_WRITE.
"""
if override is not None:
return override
cache = _cache_settings(manifest)
if cache.get("enabled") is False:
return False
if isinstance(cache.get("write"), bool):
return cache["write"]
env = _bool_env("MAS_LLM_CACHE_WRITE")
if env is not None:
return env
return True


Expand Down
58 changes: 58 additions & 0 deletions ctl/tests/test_cache_read_write_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Copyright (c) 2026 Cisco Systems, Inc. and its affiliates
# SPDX-License-Identifier: Apache-2.0
"""_cache_read_enabled/_cache_write_enabled: precedence is CLI override ->
spec.execution.cache.enabled (hard kill-switch) -> spec.execution.cache.
read/write -> MAS_LLM_CACHE_READ/MAS_LLM_CACHE_WRITE env var -> default true."""

from __future__ import annotations

from mas.ctl.manifest.spec_bindings import parse_execution
from mas.ctl.session.engine_factory import _cache_read_enabled, _cache_write_enabled


def _manifest(cache: dict) -> dict:
return {"spec": {"execution": {"cache": cache}}}


def test_defaults_to_true_with_no_manifest_or_env():
assert _cache_read_enabled(None) is True
assert _cache_write_enabled(None) is True


def test_manifest_read_write_fields_are_independent():
manifest = _manifest({"read": False, "write": True})
assert _cache_read_enabled(manifest) is False
assert _cache_write_enabled(manifest) is True


def test_enabled_false_is_a_hard_kill_switch_for_both():
manifest = _manifest({"enabled": False, "read": True, "write": True})
assert _cache_read_enabled(manifest) is False
assert _cache_write_enabled(manifest) is False


def test_env_var_used_when_manifest_silent(monkeypatch):
monkeypatch.setenv("MAS_LLM_CACHE_READ", "false")
monkeypatch.setenv("MAS_LLM_CACHE_WRITE", "true")
assert _cache_read_enabled(None) is False
assert _cache_write_enabled(None) is True


def test_manifest_field_wins_over_env_var(monkeypatch):
monkeypatch.setenv("MAS_LLM_CACHE_READ", "false")
manifest = _manifest({"read": True})
assert _cache_read_enabled(manifest) is True


def test_cli_override_wins_over_everything(monkeypatch):
monkeypatch.setenv("MAS_LLM_CACHE_READ", "true")
manifest = _manifest({"enabled": False, "read": True})
assert _cache_read_enabled(manifest, override=False) is False
assert _cache_write_enabled(manifest, override=True) is True


def test_manifest_read_write_fields_pass_schema_validation():
"""spec.execution.cache.read/write must actually validate -- a manifest
declaring them, not just an env var or CLI flag, is one of the three
documented ways to set this."""
parse_execution({"cache": {"enabled": True, "read": False, "write": True}})
4 changes: 4 additions & 0 deletions docs/includes/mas-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ $XDG_CACHE_HOME/mas/traces
$XDG_CACHE_HOME/mas/artifacts
--8<-- [end:xdg-artifacts-cache]

--8<-- [start:xdg-llm-cache]
$XDG_CACHE_HOME/mas/llm_cache.json
--8<-- [end:xdg-llm-cache]

--8<-- [start:xdg-last-run]
$XDG_STATE_HOME/mas/last-run.json
--8<-- [end:xdg-last-run]
Expand Down
1 change: 1 addition & 0 deletions docs/manifests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ config.yaml ← project defaults (flavour, infra_refs, .env path)
| Layer | Manifest kinds | Reference |
|-------|----------------|-----------|
| **Agent** | `Agent` | [agent.md](agent.md) |
| **Execution** | `spec.execution` (part of `Agent`) | [execution.md](execution.md) |
| **MAS** | `MAS`, `Workflow` | [mas.md](mas.md), [workflow.md](workflow.md) |
| **Override** | `Overlay` | [overlay.md](overlay.md) |
| **Environment** | `Flavour`, `InfraBundle`, `LLMProxy` | [flavour.md](flavour.md), [infra.md](infra.md) |
Expand Down
3 changes: 2 additions & 1 deletion docs/manifests/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ sees, and which plugins hook its execution.
| Memory | `memory`, `memory_seed` | Stores + startup seeds |
| Working memory | `working_memory.persistent` | Cross-turn buffer survives repeat delegate calls within one session (default `true`) — see below |
| Kernel plugins | `plugins[]`, `governance[]`, `observability[]` | Governance and observability on Mealy envelope chokepoints (not a hook plane) |
| Execution bounds | `execution` | Timeouts, retries |
| Execution mode | `execution` | Mocking, LLM response cache read/write, parallel tool calls — see [execution.md](execution.md) |

---

Expand Down Expand Up @@ -171,6 +171,7 @@ curl http://localhost:8090/api/schemas/agent

## See also

- [Execution parameters](execution.md) — `spec.execution`: mocking, LLM cache, parallel tool calls
- [MAS manifest](mas.md) — topology and transport
- [Overlay manifest](overlay.md) — overrides
- [Tutorial: building an agent](../tutorials/01-building-an-agent/README.md)
Expand Down
Loading
Loading