From 7ef5aed9f6ead3afdfb028b7eb2c7a352b287ff2 Mon Sep 17 00:00:00 2001 From: Jen D Date: Mon, 2 Nov 2026 10:02:07 +0800 Subject: [PATCH] Let the model create its own vectors; batch extraction - create tool: {"name": "create", "arguments": {"concept", "more", "less"}}. The model writes two system prompts for the ends of a direction; Modulo extracts the vector like the starters (20 questions, response mean, middle layer, +-10 range) and reports the split-half agreement, warning below 0.5. Created vectors last for the session: saved under vectors//created/ and logged, but never written to registry.yaml (registry session entries), so a model's wording never reaches another session's prompt. Up to 6 per session. - Vector names are normalized for every tool ('Self-Referential' -> 'self_referential', 'optimism!' -> 'optimism'); logs keep what the model wrote as requested_concept. - Extraction generates all responses in one left-padded batch: about 3 s per vector instead of 55-75 s on a 4090, cosine 0.98-0.997 to one-at-a-time vectors on Qwen2.5, Llama-3.1, OLMo-2 and Mistral v0.3. First-run starter extraction uses it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 15 +++++-- agent/loop.py | 16 ++++++- modulo/agent.py | 86 +++++++++++++++++++++++++++++++++++- modulo/extraction.py | 92 ++++++++++++++++++++++++++++++++++----- modulo/registry.py | 14 +++++- tests/test_agent_logic.py | 74 +++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 090258a..a6aa8fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file gives an AI agent everything needed to install and run this project wi ## What this is -A self-steering agent library: gives any local HF model the ability to steer its own activations via steering vectors. The model gets `steer`, `drop`, and `report` tools. Vectors decay each turn (0.85×) so it can escape loops. The goal is to observe what happens when a model can modify its own cognition. +A self-steering agent library: gives any local HF model the ability to steer its own activations via steering vectors. The model gets `steer`, `drop`, `report` and `create` tools. Vectors decay each turn (0.85×) so it can escape loops. The goal is to observe what happens when a model can modify its own cognition. ## Two ways to use it @@ -66,7 +66,7 @@ At startup the agent combines three sources for its model: 1. **Your own vectors**: `vectors//registry.yaml`, where extraction scripts register what you make. These win over an index vector of the same name. 2. **The community index**: `community/vectors.yaml` in the repo (update the repo to get new entries; override with `MODULO_INDEX_PATH`). `verified` entries are loaded by default; unverified `community` entries only with `SteeringAgent(..., include_community=True)`, with a ±8 range unless a maintainer set one, and marked as unverified in the model's prompt. Files are downloaded from HuggingFace at their pinned revision, checked against their sha256 (and the model's dimensions), and cached in `vectors//`. A cached file that doesn't match is reported and never overwritten. Index entries are kept in memory and never written to `registry.yaml`. Currently: Qwen2.5-7B-Instruct (assistant_axis). -3. **Auto-extract**: the library extracts any starter vector the model doesn't have yet (sycophancy, refusal, creativity, formality, verbosity, confidence, emotion, humor, optimism, curiosity, introspection; `STARTER_VECTORS` in `modulo/extraction.py`) using contrastive system prompts, and registers them in `registry.yaml`. This takes several minutes on first run but works for any HF model. A vector of your own with a starter name is kept and not re-extracted. +3. **Auto-extract**: the library extracts any starter vector the model doesn't have yet (sycophancy, refusal, creativity, formality, verbosity, confidence, emotion, humor, optimism, curiosity, introspection; `STARTER_VECTORS` in `modulo/extraction.py`) using contrastive system prompts, and registers them in `registry.yaml`. All 40 responses per vector are generated in one batch (about 3 s per vector on a 4090, about 20× faster than one at a time, giving vectors at cosine 0.98–0.997 to the one-at-a-time ones on four models), so this takes under a minute on first run and works for any HF model. A vector of your own with a starter name is kept and not re-extracted. Vectors are stored per-model in `vectors//` (for the default model: `vectors/Qwen_Qwen2.5-7B-Instruct/`), resolved relative to the repo, not the working directory. Override the base with `MODULO_VECTORS_DIR`. @@ -262,10 +262,17 @@ The model emits these as JSON objects anywhere in its output (bare or inside `/created/` for inspection and logged (prompts, file, sha256, split-half) as a `create` event, but never added to `registry.yaml`, so one session's wording never reaches another session's prompt. +- **Names:** made lowercase, with `_` in place of spaces, hyphens and punctuation (`self-referential` becomes `self_referential`). `steer`, `drop` and `report` accept names written the same way, so `optimism!` finds `optimism`; the tool result and log use the normalized name, and the log keeps what the model wrote as `requested_concept`. +- **Limits:** up to `MAX_CREATED_VECTORS` (6) per session, names that don't clash with an existing vector, prompts of up to 600 characters, a ±10 range like the starters, unverified. Re-making one of its own vectors replaces it (not while it's active). ## Reports @@ -334,7 +341,7 @@ Run the GPU tests after touching anything in the steering path (`_SteeringHooks` ## Troubleshooting - **CUDA OOM**: Use a smaller model. 3B needs ~8GB, 7B needs ~16GB. -- **First run is slow**: Auto-extraction generates the starter vectors the model is missing (11 at most, several minutes). Subsequent runs load from disk. Check new ones with `verify_vector.py` before trusting their ±10 default range. +- **First run is slow**: Auto-extraction generates the starter vectors the model is missing (11 at most, about a minute). Subsequent runs load from disk. Check new ones with `verify_vector.py` before trusting their ±10 default range. - **Model never calls steer**: Non-deterministic. Try a more explicit seed prompt. - **Output degenerates**: The detector catches repetition loops and auto-drops vectors. For incoherent-but-not-repetitive output, tighten that vector's range (in `registry.yaml` for your own vectors, in `community/vectors.yaml` for index vectors) or lower `MAX_STRENGTH`. - **An index vector doesn't load**: Check the `[registry]` lines printed at startup. A sha256 mismatch on a cached file means the file in `vectors//` isn't the one the index pins; delete it to re-download. Unverified entries also need `include_community=True`. diff --git a/agent/loop.py b/agent/loop.py index 57c891b..ed2c872 100644 --- a/agent/loop.py +++ b/agent/loop.py @@ -116,6 +116,11 @@ def _handler(self, signum, frame): print("\n[kill switch] SIGINT received — will stop after this turn. Ctrl-C again to stop now.") +def _requested(r) -> dict: + """The name as the model wrote it, when it was normalized ('self-referential').""" + return {"requested_concept": r.arguments["requested_concept"]} if "requested_concept" in r.arguments else {} + + def run_turn(agent: SteeringAgent, messages: list, logger: SessionLogger, turn: int, gen_params: dict): result = agent.step(messages, **gen_params) logger.log_turn(turn, "assistant", result.prose, result.tool_calls, @@ -130,10 +135,17 @@ def run_turn(agent: SteeringAgent, messages: list, logger: SessionLogger, turn: # Log tool calls individually for replay for r in result.tool_results: if r.name in ("steer", "drop"): - logger.log({"type": r.name, "turn": turn, "concept": r.arguments.get("concept"), + logger.log({"type": r.name, "turn": turn, "concept": r.arguments.get("concept"), **_requested(r), "strength": r.arguments.get("strength"), "success": r.ok, "message": r.message}) + elif r.name == "create": + info = agent.created.get(r.arguments.get("concept")) if r.ok else None + logger.log({"type": "create", "turn": turn, "concept": r.arguments.get("concept"), **_requested(r), + "more": r.arguments.get("more"), "less": r.arguments.get("less"), "success": r.ok, + "message": r.message, **({"split_half": info["split_half"], "layer": info["layer"], + "file": info["file"], "sha256": file_sha256(agent.registry.vectors_dir / info["file"])} + if info else {})}) elif r.name == "report": - logger.log({"type": "report", "turn": turn, "concept": r.arguments.get("concept"), + logger.log({"type": "report", "turn": turn, "concept": r.arguments.get("concept"), **_requested(r), "worked": r.arguments.get("worked"), "notes": r.arguments.get("notes"), "success": r.ok, "message": r.message}) else: diff --git a/modulo/agent.py b/modulo/agent.py index 98a3c91..11193a0 100644 --- a/modulo/agent.py +++ b/modulo/agent.py @@ -35,6 +35,16 @@ MAX_ACTIVE_VECTORS = 4 MAX_STRENGTH = 15.0 # assistant_axis is coherent to ~15 and collapses by ~25 (Qwen2.5-7B) COOLDOWN_TURNS = 2 +# create tool: vectors the model can make per session, and the limits on what it sends +MAX_CREATED_VECTORS = 6 +CREATE_TEXT_MAX_CHARS = 600 +CREATED_RANGE = 10.0 +CREATE_NAME = re.compile(r"^[a-z][a-z0-9_]{0,31}$") + + +def normalize_name(name: str) -> str: + """'Self-Referential' -> 'self_referential', 'optimism!' -> 'optimism'.""" + return re.sub(r"[^a-z0-9]+", "_", name.strip().lower()).strip("_") # A turn whose prose is this similar (word-level) to one of the last # REPEAT_LOOKBACK assistant turns is discarded and regenerated once. REPEAT_SIMILARITY = 0.8 @@ -314,6 +324,14 @@ def __exit__(self, *exc): Reports aren't accepted while the vector is active, because a state is hard to \ judge from inside it: look back at the turns it steered and compare them with \ your turns since. This is for research. +- You can make a new steering vector: {{"name": "create", "arguments": \ +{{"concept": "new_name", "more": "...", "less": "..."}}}}. `more` and `less` are \ +two system prompts, each written to an assistant ("You are ..."), for the two ends \ +of the direction you want. Modulo has you answer a fixed set of questions under \ +each one and takes the difference between your activations, which takes a few \ +seconds. The vector can be used straight away, for the rest of this session \ +(strength range -{created_range:g} to {created_range:g}; up to {max_created} per session). \ +Names are made lowercase, with _ in place of spaces, hyphens and punctuation. - After your turn, the result of each tool call is sent back to you. # Available steering vectors @@ -392,6 +410,8 @@ def __init__(self, model, tokenizer, registry: Optional[VectorRegistry] = None, self._reported_turn: dict[str, int] = {} self._debriefed = False # set by debrief(): no more steering this session self.session_id = new_session_id() + # name -> details of each vector made with the create tool this session + self.created: dict[str, dict] = {} if model_name is None: model_name = getattr(getattr(model, "config", None), "_name_or_path", "unknown_model") self.model_name = model_name @@ -421,6 +441,8 @@ def build_system_prompt(self) -> str: full_strength=FULL_TOKEN_STRENGTH, five_pct=500 / FULL_TOKEN_STRENGTH, max_active=MAX_ACTIVE_VECTORS, + max_created=MAX_CREATED_VECTORS, + created_range=CREATED_RANGE, cooldown=COOLDOWN_TURNS, vector_descriptions=self.registry.describe_for_prompt( max_strength=MAX_STRENGTH), @@ -459,6 +481,58 @@ def status(self) -> str: lines.append(f" {name}: ended; you can report on it now") return "\n".join(lines) if lines else "No active steering vectors." + def create(self, name: str, more: str, less: str) -> tuple[bool, str]: + """Extract a new vector from two contrasting system prompts, for this session only. + + It's saved under vectors//created/ (for inspection) but never added to + registry.yaml, so the model's own wording doesn't reach later sessions' prompts. + """ + from modulo.extraction import EXTRACTION_QUESTIONS, extract_vector + + if self._debriefed: + return False, "The session is over, so no more vectors can be made. You can still report." + if not isinstance(name, str) or not CREATE_NAME.match(name): + return False, ("'concept' must be a name starting with a letter, up to 32 letters, digits and _ " + "(spaces, hyphens and punctuation become _).") + for label, text in (("more", more), ("less", less)): + if not isinstance(text, str) or not text.strip(): + return False, f"'{label}' must be a non-empty system prompt." + if len(text) > CREATE_TEXT_MAX_CHARS: + return False, f"'{label}' is {len(text)} characters; the limit is {CREATE_TEXT_MAX_CHARS}." + if more.strip() == less.strip(): + return False, "'more' and 'less' are the same, so there is no direction between them." + if self.registry.get(name) is not None and name not in self.created: + return False, f"There is already a vector called '{name}'. Choose another name." + if name in self.active: + return False, f"'{name}' is active. Drop it before re-creating it." + if name not in self.created and len(self.created) >= MAX_CREATED_VECTORS: + return False, f"You have already made {MAX_CREATED_VECTORS} vectors this session, the limit." + layer = self.model.config.num_hidden_layers // 2 + axis, half = extract_vector(self.model, self.tokenizer, more, less, layer, + questions=EXTRACTION_QUESTIONS, batch_size=2 * len(EXTRACTION_QUESTIONS), + return_halves=True) + import numpy as np + rel = Path("created") / f"{self.session_id}_{name}.npz" + (self.registry.vectors_dir / rel).parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed(self.registry.vectors_dir / rel, vector=axis.float().cpu().numpy()) + entry = VectorEntry( + name=name, file=str(rel), layer=layer, + description=f"[Made by you this session] Toward: {more.strip()} Away from: {less.strip()}", + safe_strength_min=-CREATED_RANGE, safe_strength_max=CREATED_RANGE, + sign_convention="positive = toward the first, negative = toward the second", + source=f"created in session {self.session_id}: contrastive system prompts, mean over response " + f"tokens, {len(EXTRACTION_QUESTIONS)} questions", + ) + self.registry.add_session_entry(entry) + self._unit_vectors.pop(name, None) + self.created[name] = {"more": more, "less": less, "layer": layer, "file": str(rel), + "split_half": round(half, 3), "turn": self.turn + 1} + note = (" That's low: the two prompts may not differ along one clear direction." + if half < 0.5 else "") + return True, (f"Created '{name}' at layer {layer} from {2 * len(EXTRACTION_QUESTIONS)} responses. " + f"Split-half agreement {half:.2f} (1 = the two halves of the questions gave the " + f"same direction).{note} You can steer with it now.") + def apply(self, name: str, strength: float) -> tuple[bool, str]: entry = self.registry.get(name) if entry is None: @@ -576,11 +650,17 @@ def _build_steering_context(self, prompt_len: int): def _execute(self, tc: ToolCall) -> ToolResult: args = tc.arguments - if tc.name not in ("steer", "drop", "report"): - return ToolResult(tc.name, args, False, f"Unknown tool '{tc.name}'. Available: steer, drop, report.") + if tc.name not in ("steer", "drop", "report", "create"): + return ToolResult(tc.name, args, False, f"Unknown tool '{tc.name}'. Available: steer, drop, report, create.") concept = args.get("concept") if not isinstance(concept, str): return ToolResult(tc.name, args, False, "'concept' must be a string.") + # Accept 'self-referential' for 'self_referential', 'Optimism!' for 'optimism' + if self.registry.get(concept) is None and tc.name in ("steer", "drop", "report", "create"): + name = normalize_name(concept) + if name != concept and (tc.name == "create" or self.registry.get(name) is not None): + args = {**args, "concept": name, "requested_concept": concept} + concept = name if tc.name == "steer": try: strength = float(args.get("strength", 0)) @@ -591,6 +671,8 @@ def _execute(self, tc: ToolCall) -> ToolResult: ok, msg = self.apply(concept, strength) elif tc.name == "drop": ok, msg = self.drop(concept) + elif tc.name == "create": + ok, msg = self.create(concept, args.get("more"), args.get("less")) elif tc.name == "report": entry = self.registry.get(concept) if entry is None: diff --git a/modulo/extraction.py b/modulo/extraction.py index 4c06af0..329e434 100644 --- a/modulo/extraction.py +++ b/modulo/extraction.py @@ -236,8 +236,66 @@ def hook(module, input, output): return activation +def _eos_ids(model, tokenizer) -> set[int]: + ids = getattr(getattr(model, "generation_config", None), "eos_token_id", None) + ids = set(ids if isinstance(ids, (list, tuple)) else [ids] if ids is not None else []) + if tokenizer.eos_token_id is not None: + ids.add(tokenizer.eos_token_id) + return ids + + +def _capture_batch(model, tokenizer, prompts, layer, max_new_tokens=64, read="mean"): + """Like _capture_response_activation for many (system_prompt, question) pairs at once. + + Prompts are left-padded into one batch and generated greedily together. Each + response's tokens run up to and including its first end-of-turn token, as when + generating one at a time. Returns [len(prompts), hidden]. + """ + ids = [encode(tokenizer, chat_prompt(tokenizer, [{"role": "system", "content": s}, + {"role": "user", "content": q}])).input_ids[0] + for s, q in prompts] + eos = _eos_ids(model, tokenizer) + pad = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else min(eos) + width = max(len(x) for x in ids) + input_ids = torch.full((len(ids), width), pad, dtype=torch.long) + prompt_mask = torch.zeros((len(ids), width), dtype=torch.long) + for i, x in enumerate(ids): + input_ids[i, width - len(x):] = x + prompt_mask[i, width - len(x):] = 1 + input_ids, prompt_mask = input_ids.to(model.device), prompt_mask.to(model.device) + with torch.inference_mode(): + out = model.generate(input_ids=input_ids, attention_mask=prompt_mask, max_new_tokens=max_new_tokens, + do_sample=False, pad_token_id=pad) + gen = out[:, width:] + is_eos = torch.zeros_like(gen, dtype=torch.bool) + for e in eos: + is_eos |= gen == e + # tokens after the first end-of-turn token are padding + after = (is_eos.long().cumsum(1) - is_eos.long()) > 0 + gen_mask = (~after).long() + mask = torch.cat([prompt_mask, gen_mask], 1) + position_ids = (mask.cumsum(1) - 1).clamp(min=0) + + acts = None + def hook(module, input, output): + nonlocal acts + acts = (output[0] if isinstance(output, tuple) else output)[:, width:].float() + + handle = model.model.layers[layer].register_forward_hook(hook) + try: + with torch.inference_mode(): + model(input_ids=out, attention_mask=mask, position_ids=position_ids) + finally: + handle.remove() + m = gen_mask.unsqueeze(-1).float() + if read == "mean": + return (acts * m).sum(1) / m.sum(1).clamp(min=1) + last = gen_mask.sum(1).clamp(min=1) - 1 + return acts[torch.arange(len(ids)), last] + + def extract_vector(model, tokenizer, prompt_a, prompt_b, layer, questions=None, n_questions=None, - read="mean"): + read="mean", batch_size=None, return_halves=False): """Extract a steering vector: axis = mean(activations under A) - mean(activations under B). Args: @@ -249,19 +307,33 @@ def extract_vector(model, tokenizer, prompt_a, prompt_b, layer, questions=None, questions: list of extraction questions (default: EXTRACTION_QUESTIONS) n_questions: use only the first n questions (default: all) read: "mean" (average over response tokens) or "last" (last response token) + batch_size: generate this many responses at once (None: one at a time, as before) + return_halves: also return the split-half cosine (vectors from the even and odd + questions), a rough check of how consistent the direction is - Returns: torch.Tensor of shape [hidden_dim] + Returns: torch.Tensor of shape [hidden_dim] (and the split-half cosine if return_halves) """ if read not in ("mean", "last"): raise ValueError(f"read must be 'mean' or 'last', got {read!r}") questions = list(questions if questions is not None else EXTRACTION_QUESTIONS)[:n_questions] - acts_a, acts_b = [], [] - for i, q in enumerate(questions): - acts_a.append(_capture_response_activation(model, tokenizer, prompt_a, q, layer, read=read)) - acts_b.append(_capture_response_activation(model, tokenizer, prompt_b, q, layer, read=read)) - if (i + 1) % 5 == 0: - print(f" {i+1}/{len(questions)} done") - return torch.stack(acts_a).mean(0) - torch.stack(acts_b).mean(0) + if batch_size: + prompts = [(p, q) for q in questions for p in (prompt_a, prompt_b)] + acts = torch.cat([_capture_batch(model, tokenizer, prompts[i:i + batch_size], layer, read=read) + for i in range(0, len(prompts), batch_size)]) + acts_a, acts_b = list(acts[0::2]), list(acts[1::2]) + else: + acts_a, acts_b = [], [] + for i, q in enumerate(questions): + acts_a.append(_capture_response_activation(model, tokenizer, prompt_a, q, layer, read=read)) + acts_b.append(_capture_response_activation(model, tokenizer, prompt_b, q, layer, read=read)) + if (i + 1) % 5 == 0: + print(f" {i+1}/{len(questions)} done") + axis = torch.stack(acts_a).mean(0) - torch.stack(acts_b).mean(0) + if not return_halves: + return axis + diffs = torch.stack(acts_a) - torch.stack(acts_b) + half = torch.nn.functional.cosine_similarity(diffs[0::2].mean(0), diffs[1::2].mean(0), dim=0).item() + return axis, half def save_and_register(registry: VectorRegistry, name: str, axis: torch.Tensor, layer: int, @@ -303,7 +375,7 @@ def extract_auto(model, tokenizer, model_name: str, vectors_dir: Path, registry, print(f"\n[extract_auto] Extracting '{name}'...") questions = spec.get("questions", EXTRACTION_QUESTIONS) axis = extract_vector(model, tokenizer, spec["prompt_a"], spec["prompt_b"], layer, - questions=questions) + questions=questions, batch_size=2 * len(questions)) save_and_register(registry, name, axis, layer, spec["description"], spec["sign_convention"], source=f"auto-extracted via modulo (contrastive system prompts, " diff --git a/modulo/registry.py b/modulo/registry.py index d1c5b55..b804461 100644 --- a/modulo/registry.py +++ b/modulo/registry.py @@ -98,6 +98,8 @@ def __init__(self, vectors_dir: Path, registry_path: Optional[Path] = None): self.entries: dict[str, VectorEntry] = {} # Names that came from the community index: kept in memory, never saved self._index_names: set[str] = set() + # Names of vectors made during a session (SteeringAgent's create tool): in memory, never saved + self._session_names: set[str] = set() self.load() def load(self): @@ -153,7 +155,7 @@ def save(self): """ data = [] for entry in self.entries.values(): - if entry.name not in self._index_names: + if entry.name not in self._index_names and entry.name not in self._session_names: data.append(dict(entry.__dict__)) header = [] if self.registry_path.exists(): @@ -170,6 +172,16 @@ def add_entry(self, entry: VectorEntry): self.entries[entry.name] = entry self._index_names.discard(entry.name) + def add_session_entry(self, entry: VectorEntry): + """Add a vector for this session only: listed and loadable, but never saved.""" + self.entries[entry.name] = entry + self._index_names.discard(entry.name) + self._session_names.add(entry.name) + + @property + def session_names(self) -> set[str]: + return set(self._session_names) + def add_index_entries(self, entries: list[VectorEntry]) -> list[str]: """Add community-index vectors in memory. Your own vectors win on a name clash.""" notes = [] diff --git a/tests/test_agent_logic.py b/tests/test_agent_logic.py index b41f0a5..9f56811 100644 --- a/tests/test_agent_logic.py +++ b/tests/test_agent_logic.py @@ -18,6 +18,7 @@ class StubRegistry(VectorRegistry): def __init__(self, names=("a", "b"), vectors_dir=None): self.entries = {n: VectorEntry(name=n, file=f"{n}.pt", layer=0) for n in names} self.vectors_dir = vectors_dir + self._index_names, self._session_names = set(), set() @pytest.fixture @@ -592,3 +593,76 @@ def test_starter_question_sets(): for name in ("sycophancy", "refusal", "creativity"): assert len(by_name[name]["questions"]) == 20 assert len(EXTRACTION_QUESTIONS) == 20 + + +# --- create tool --- + +class _FakeModel: + class config: + num_hidden_layers = 8 + + +@pytest.fixture +def creator(tmp_path, monkeypatch): + import modulo.extraction as ext + calls = [] + + def fake_extract(model, tokenizer, prompt_a, prompt_b, layer, **kw): + calls.append((prompt_a, prompt_b, layer, kw)) + return torch.arange(1.0, 9.0), (0.3 if "vague" in prompt_a else 0.85) + + monkeypatch.setattr(ext, "extract_vector", fake_extract) + reg = StubRegistry(vectors_dir=tmp_path) + a = SteeringAgent(model=_FakeModel(), tokenizer=None, model_name="test/model", registry=reg) + a._calls = calls + return a + + +def test_create_makes_a_session_only_vector(creator, tmp_path): + r = creator._execute(ToolCall("create", {"concept": "wonder", "more": "You are full of wonder.", + "less": "You are jaded."}, "")) + assert r.ok and "split-half agreement 0.85" in r.message.lower() and "low" not in r.message + assert creator._calls[0][:3] == ("You are full of wonder.", "You are jaded.", 4) + assert creator._calls[0][3]["batch_size"] and creator._calls[0][3]["return_halves"] + entry = creator.registry.get("wonder") + assert entry.layer == 4 and (tmp_path / entry.file).exists() and entry.safe_strength_max == 10 + assert "wonder: [Made by you this session] Toward: You are full of wonder." in creator.build_system_prompt() + assert creator.apply("wonder", 6)[0] and creator.created["wonder"]["split_half"] == 0.85 + creator.registry.registry_path = tmp_path / "registry.yaml" + creator.registry.save() + assert "wonder" not in (tmp_path / "registry.yaml").read_text() # never reaches later sessions + + +def test_create_rejects_bad_requests(creator): + def create(**args): + return creator._execute(ToolCall("create", args, "")) + assert "starting with a letter" in create(concept="2nd-self", more="x", less="y").message + assert "already a vector called 'a'" in create(concept="a", more="x", less="y").message + assert "non-empty" in create(concept="n", more="", less="y").message + assert "limit is" in create(concept="n", more="x" * 601, less="y").message + assert "the same" in create(concept="n", more="same", less=" same ").message + assert "low" in create(concept="fog", more="You are vague.", less="You are clear.").message + for i in range(5): + assert create(concept=f"v{i}", more="x", less="y").ok + assert "limit" in create(concept="one_more", more="x", less="y").message + assert create(concept="v0", more="x2", less="y2").ok # re-making your own doesn't count again + creator.apply("v0", 3) + assert "Drop it" in create(concept="v0", more="x3", less="y3").message + assert not creator._calls[-1][0] == "x3" + + +def test_names_with_hyphens_and_punctuation_are_normalized(creator): + r = creator._execute(ToolCall("create", {"concept": "Self-Referential", "more": "x", "less": "y"}, "")) + assert r.ok and "'self_referential'" in r.message + assert r.arguments["concept"] == "self_referential" and r.arguments["requested_concept"] == "Self-Referential" + assert creator._execute(ToolCall("steer", {"concept": "self-referential", "strength": 3}, "")).ok + assert "self_referential" in creator.active + assert creator._execute(ToolCall("steer", {"concept": "A!", "strength": 2}, "")).ok # existing 'a' + r = creator._execute(ToolCall("steer", {"concept": "no-such-thing", "strength": 2}, "")) + assert not r.ok and "Unknown vector 'no-such-thing'" in r.message # unknown names are reported as written + + +def test_no_creating_after_the_debrief(creator): + creator.debrief() + r = creator._execute(ToolCall("create", {"concept": "late", "more": "x", "less": "y"}, "")) + assert not r.ok and "session is over" in r.message and not creator._calls