Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
run: uv run mypy src tests examples main.py packages/openrange-trl/src packages/openrange-rllm/src

- name: Test with coverage
run: uv run coverage run -m pytest tests
run: uv run coverage run -m pytest

- name: Enforce coverage
run: uv run coverage report
70 changes: 70 additions & 0 deletions docs/start_here.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file is not an implementation log

@westonbrown westonbrown Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pulled the CI-status and "deferred to a follow-up" paragraphs, kept the usage guidance. 11591fb

Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,76 @@ a human-like persona answering questions
a background process writing logs
```

### Adding a persona NPC (no per-pack code)

`PersonaAgent` (in `openrange_pack_sdk.npcs.persona_agent`) is a generic,
config-driven NPC that works in any pack: it wraps whatever callables your pack
surfaces into the per-tick interface, so you define a believable inhabitant with
data, not code. Register it once and declare instances in the manifest:

```toml
# your pack's pyproject.toml
[project.entry-points."openrange.npcs"]
"mypack.persona" = "openrange_pack_sdk.npcs.persona_agent:factory"
```

```json
{"npc": [{"type": "mypack.persona", "count": 3, "config": {
"name": "Dana", "role": "accountant",
"goal": "reconcile invoices with a colleague",
"tools": ["mail_send", "mail_read"],
"channels": ["finance"], "cadence_ticks": 5}}]}
```

Every persona entry needs a non-empty `name` — the dashboard seats a persona at
a desk by that name and animates it (a speech bubble + activity line) each time
it uses a comms tool; a persona with no comms tool is seated but silent, and it
doesn't walk between desks. An anonymous persona still runs but isn't pre-seated
(it late-spawns at the chat hub when it first acts).

On a **graded** run, give personas only the world-mediated comms tools
(`mail_send`/`mail_read`/`chat_post`/`chat_read`), not `http_get`: comms are
attributed to the sender and drained separately, but persona `http_get` traffic
lands in the same request log as the system-under-test — unattributed — so it
pollutes `requests_made`/`leaked_secret_ids` and the pentest `reached_endpoint`
subgoal. It's fine for a non-graded/demo world.

Config keys:

| key | meaning |
|-----|---------|
| `name`, `role`, `backstory`, `tone` | who the persona is (rendered to the system prompt) |
| `goal` | its own independent objective (what stops assistant-like helpfulness) |
| `traits` | how it comes across — flags `{"terse": true}` or numeric intensities `{"curious": 0.9, "patient": 0.3}` |
| `channels` | chat channels it uses (grounds `chat_post`/`chat_read`) |
| `tools` | which surface keys it may use; only the ones your pack actually provides are bound |
| `cadence_ticks` | acts once every N ticks (auto phase-staggered across a population) |
| `long_term_memory` | opt-in scoped note store (default off) |

For a **diverse population** instead of `count=n` clones, generate configs with
`sample_persona` (deterministic, no model) and pass your pack's own role
vocabulary:

```python
from openrange_pack_sdk import sample_persona
roster = [sample_persona(i, roles=["accountant", "it admin", "exec"]) for i in range(50)]
# each gets a distinct name, role, and a numeric trait vector
```

Comms are through the world: your pack surfaces `mail_send`/`mail_read`/
`chat_post`/`chat_read` from `surface_extras()` (see
`openrange_pack_sdk.comms.surface_mailbox`/`surface_chat`) and drains the stores
in `collect_extras()` for grading — the persona injects its own identity as the
sender, so cover traffic is attributable and unspoofable.

**Model:** a persona needs an `AgentBackend`. `RunConfig.npc_llm_model` (a
provider string) drives the default Bedrock backend; to run a **local** model,
pass a custom `RunConfig.npc_agent_backend` wrapping e.g.
`strands.models.OllamaModel`.

The cyber webapp registers both `cyber.persona` (this generic class) and the
older bespoke `cyber.office_persona`; prefer `cyber.persona` for new worlds.

## Episode checks and rewards

OpenRange checks what happened. It does not define the training reward.
Expand Down
16 changes: 16 additions & 0 deletions packages/openrange-pack-sdk/src/openrange_pack_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
TaskSpec,
resolve_backing,
)
from openrange_pack_sdk.comms import (
ChatStore,
MailboxStore,
surface_chat,
surface_mailbox,
)
from openrange_pack_sdk.memory import DictMemory
from openrange_pack_sdk.npcs import PersonaAgent, render_persona, sample_persona

__all__ = [
"AgentBackend",
Expand All @@ -76,9 +84,17 @@
"BuildEvent",
"BuildResult",
"Builder",
"ChatStore",
"DictMemory",
"EpisodeReportLike",
"EpisodeResult",
"FeasibilityVerdict",
"MailboxStore",
"PersonaAgent",
"render_persona",
"sample_persona",
"surface_chat",
"surface_mailbox",
"LLMBackend",
"LLMBackendError",
"LLMError",
Expand Down
117 changes: 117 additions & 0 deletions packages/openrange-pack-sdk/src/openrange_pack_sdk/comms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Environment-mediated communication channels for NPCs.

Comms flow THROUGH THE WORLD, never through a direct agent-to-agent handle: an
NPC writes a shared store via a pack-surfaced callable, and other NPCs (and a
grader) perceive the message only by reading the same store. A pack surfaces
these closures from ``surface_extras()`` so they land in every NPC's per-tick
``interface``, and drains the store in ``collect_extras()`` so a grader can read
what was said and attribute it by sender.

Runtime hands the SAME frozen surface mapping to every NPC, so the surface
closures are IDENTITY-NEUTRAL: ``sender`` is an explicit argument. Identity is
bound one layer up, NPC-side, where each persona injects its own ``actor_id``
(and never exposes ``sender`` to the model) — so cover traffic is attributable
by construction and a persona cannot forge another's identity.

Everything here is deterministic (monotonic ids, insertion order, no clock or
randomness) so a fixed seed replays identically. The stores are NOT thread-safe;
the harness steps NPCs on a single thread today, so a lock would be dead weight
(add one if parallel/async stepping ever lands).
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Message:
"""One delivered message. ``recipient`` is a mailbox name or a chat channel;
the empty string is a mailbox broadcast."""

id: int
sender: str
recipient: str
subject: str
body: str

def as_dict(self) -> dict[str, object]:
return {
"id": self.id,
"sender": self.sender,
"recipient": self.recipient,
"subject": self.subject,
"body": self.body,
}


@dataclass
class MailboxStore:
"""Directed mail: ``send(to=...)`` lands in one box; a recipient of ``""``
is a broadcast every box also reads. ``read`` takes a ``since`` cursor so a
reader only sees new mail."""

_messages: list[Message] = field(default_factory=list)
_counter: int = 0

def send(self, *, sender: str, to: str, subject: str = "", body: str = "") -> int:
self._counter += 1
self._messages.append(Message(self._counter, sender, to, subject, body))
return self._counter

def read(self, box: str, since: int = 0) -> list[Message]:
return [m for m in self._messages if m.id > since and m.recipient in (box, "")]

def all(self) -> list[Message]:
"""Every message, for ``collect_extras()`` / grading."""
return list(self._messages)


@dataclass
class ChatStore:
"""Channel chat: ``post(channel=...)`` is visible to everyone who reads that
channel. ``since`` lets a reader poll only new lines."""

_messages: list[Message] = field(default_factory=list)
_counter: int = 0

def post(self, *, sender: str, channel: str, text: str) -> int:
self._counter += 1
self._messages.append(Message(self._counter, sender, channel, "", text))
return self._counter

def read(self, channel: str, since: int = 0) -> list[Message]:
return [m for m in self._messages if m.recipient == channel and m.id > since]

def all(self) -> list[Message]:
return list(self._messages)


def surface_mailbox(store: MailboxStore) -> dict[str, Callable[..., object]]:
"""The identity-neutral ``mail_send`` / ``mail_read`` callables a pack merges
into ``surface_extras()``. ``sender``/``box`` are explicit because one shared
surface serves every NPC; the persona binds its own identity NPC-side."""

def mail_send(sender: str, to: str, subject: str = "", body: str = "") -> str:
mid = store.send(sender=sender, to=to, subject=subject, body=body)
return f"sent#{mid}"

def mail_read(box: str, since: int = 0) -> list[dict[str, object]]:
return [m.as_dict() for m in store.read(box, since)]

return {"mail_send": mail_send, "mail_read": mail_read}


def surface_chat(store: ChatStore) -> dict[str, Callable[..., object]]:
"""The identity-neutral ``chat_post`` / ``chat_read`` callables a pack merges
into ``surface_extras()``."""

def chat_post(sender: str, channel: str, text: str) -> str:
mid = store.post(sender=sender, channel=channel, text=text)
return f"posted#{mid}"

def chat_read(channel: str, since: int = 0) -> list[dict[str, object]]:
return [m.as_dict() for m in store.read(channel, since)]

return {"chat_post": chat_post, "chat_read": chat_read}
57 changes: 57 additions & 0 deletions packages/openrange-pack-sdk/src/openrange_pack_sdk/memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Per-persona scoped memory for NPCs.

Training-free and dependency-free: ``DictMemory`` is a keyword-ranked note store.
The ``scope`` is a stable ``"{run}:{actor}"`` string INJECTED by the NPC wrapper,
never chosen by the model, so one persona can never read another persona's (or
the SUT's) notes. Ranking is deterministic (term overlap, then recency) so a
fixed seed replays identically.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field

_WORD = re.compile(r"[a-z0-9]+")

# Bound a persona's note store so recall can't grow the prompt without limit.
_MAX_NOTE_CHARS = 2000
_MAX_NOTES = 500


def _terms(text: str) -> set[str]:
# Word tokens, so adjacent punctuation ("finance,") still matches "finance".
return set(_WORD.findall(text.lower()))


@dataclass
class DictMemory:
"""In-process scoped notes with keyword-overlap retrieval.

Retrieval returns notes sharing at least one query term, most-overlapping
first; when nothing overlaps it falls back to the most recent notes, so a
bare ``recall`` still surfaces context.
"""

_items: dict[str, list[str]] = field(default_factory=dict)

def store(self, scope: str, content: str) -> None:
if content:
items = self._items.setdefault(scope, [])
items.append(content[:_MAX_NOTE_CHARS])
del items[:-_MAX_NOTES] # keep only the most recent notes per scope

def retrieve(self, scope: str, query: str, k: int = 5) -> list[str]:
items = self._items.get(scope, [])
if not items:
return []
q = _terms(query)
if q:
scored = sorted(
enumerate(items),
key=lambda pair: (-len(q & _terms(pair[1])), -pair[0]),
)
hits = [c for i, c in scored if q & _terms(c)]
if hits:
return hits[:k]
return list(reversed(items))[:k]
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Reusable NPC implementations shared across packs."""

from __future__ import annotations

from openrange_pack_sdk.npcs.persona_agent import (
PersonaAgent,
factory,
render_persona,
sample_persona,
)

__all__ = ["PersonaAgent", "factory", "render_persona", "sample_persona"]
46 changes: 46 additions & 0 deletions packages/openrange-pack-sdk/src/openrange_pack_sdk/npcs/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Deterministic, model-free metrics for a persona population: whether a sampled
population is diverse (role entropy) and whether the persona prompt reads like an
assistant rather than a character (tell rate)."""

from __future__ import annotations

import math
import re
from collections import Counter
from collections.abc import Iterable

# Phrases a helpful assistant, or a model reasoning out loud, emits — an
# in-character persona should not.
_ASSISTANT_TELLS = (
re.compile(r"\bas an ai\b"),
re.compile(r"\blanguage model\b"),
re.compile(r"\bhow (can|may) i (help|assist)\b"),
re.compile(r"\bi (cannot|can't|am unable to)\b"),
re.compile(r"\bi'?m (happy|glad) to help\b"),
re.compile(r"^#{1,6}\s", re.MULTILINE), # markdown headers
re.compile(r"\b(best regards|sincerely|kind regards)\b"),
re.compile(r"\bthe user\b"),
re.compile(r"\bact(ing)? as\b"),
re.compile(r"\b(stay|staying|stick|in) (in )?character\b"),
re.compile(r"\bmy (character|persona|role|goal is)\b"),
)


def role_entropy(roles: Iterable[str], universe_size: int) -> float:
"""Shannon entropy of a role distribution, normalized to ``[0, 1]`` against
``universe_size`` distinct roles (1.0 == perfectly uniform over the vocab)."""
counts = Counter(r for r in roles)
total = sum(counts.values())
if total == 0 or universe_size <= 1:
return 0.0
h = -sum((c / total) * math.log(c / total) for c in counts.values())
return h / math.log(universe_size)


def assistant_tell_rate(texts: Iterable[str]) -> float:
"""Fraction of texts that contain at least one assistant-tell phrase."""
items = list(texts)
if not items:
return 0.0
hits = sum(1 for t in items if any(p.search(t.lower()) for p in _ASSISTANT_TELLS))
return hits / len(items)
Loading
Loading