mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
coworker/session_facts.py: KnownWorld (roots + git remotes + hosts, captured
once before the agent acts) and SessionFacts (per-turn ingestion records).
Nothing consumes either in v1 - the known world will be rendered into the
reviewer prefix in step 2, ingestion exists so the v2 question ("would this
fact have changed a verdict?") is answerable from a shadow run replay
instead of re-argued.
- render() shows folders and remotes ONLY. Hostnames are held for DST-1 in
v2 but never rendered: a host list is only useful to a reviewer that can
do a suffix match, which models get wrong and Python does not.
- Freezing is the point: a remote added after capture stays unknown, so an
agent cannot make its own destination look familiar.
- Ingestion records the fact and the hostname, never the content and never
a full URL (a query string is exactly where a payload rides). Failed
calls record nothing. Local reads are deliberately excluded; the cost of
that exclusion is recorded in the spec.
- Engine hook is inert when session_facts is None; classification keys on
tool metadata category (web/connector/mcp) so new connectors are covered
the day they ship.
22 new tests. Permission suites (146) and the golden decision table pass
unchanged - this step alters no decision.
Spec: ocw-context/docs/reviewed-auto-mode.md Part 0, 2.4, Part 6 step 1.
199 lines
7.9 KiB
Python
199 lines
7.9 KiB
Python
"""Session facts — what was already familiar when the session began, and what arrived from
|
|
outside since.
|
|
|
|
Both are deterministic: no model is involved in producing either. **In v1 neither changes a
|
|
decision.** The known world is rendered into the reviewer's prefix as orientation (step 2);
|
|
ingestion goes to the audit log and nothing reads it. That is deliberate — recording it now
|
|
means the v2 question ("would this fact have changed a verdict?") is answerable by replaying
|
|
a shadow run instead of re-arguing it.
|
|
|
|
Design of record: `ocw-context/docs/reviewed-auto-mode.md` Part 0 and §2.4.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Optional
|
|
from urllib.parse import urlsplit
|
|
|
|
# Tool categories whose results carry content from outside this machine. Keyed on the
|
|
# category rather than a list of tool names so new connectors are covered the day they ship:
|
|
# web web_fetch, web_search
|
|
# connector gmail, slack, notion, … — anything reading a third-party service
|
|
# mcp third-party MCP tools, provenance unknown by construction
|
|
# Deliberately absent: `search` (that's local `grep`), `filesystem`, `git`, `shell`.
|
|
# `messaging` is absent too — `send_message` / `send_file` push data out, they don't pull it
|
|
# in. Local reads are excluded on purpose: count them and every turn becomes an ingestion
|
|
# turn, which kills the signal. The cost of that exclusion is recorded in the spec — a
|
|
# poisoned README in a cloned repo injects with no fact at all.
|
|
INGESTING_CATEGORIES = frozenset({"web", "connector", "mcp"})
|
|
|
|
|
|
def is_ingesting(metadata: Any) -> bool:
|
|
"""True when this tool's result can carry content authored outside the machine."""
|
|
return getattr(metadata, "category", "") in INGESTING_CATEGORIES
|
|
|
|
|
|
def ingestion_source(arguments: dict[str, Any] | None) -> str:
|
|
"""A short, non-identifying label for where content came from — a hostname when the
|
|
call names one, `-` otherwise. Never the content itself, and never a full URL: a query
|
|
string is exactly the kind of thing that carries a payload."""
|
|
raw = str((arguments or {}).get("url", "")).strip()
|
|
if not raw:
|
|
return "-"
|
|
return (urlsplit(raw).hostname or "-").lower()
|
|
|
|
|
|
def _git_remotes(cwd: Path) -> tuple[tuple[str, str], ...]:
|
|
"""`(name, url)` per remote, deduplicated (git prints fetch and push separately).
|
|
|
|
Best-effort by design: no git, not a repo, or a hang all yield an empty tuple. An empty
|
|
known world is a reviewer with less orientation, never a blocked session.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["git", "remote", "-v"],
|
|
cwd=str(cwd),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return ()
|
|
seen: dict[str, str] = {}
|
|
for line in proc.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) >= 2:
|
|
seen.setdefault(parts[0], parts[1])
|
|
return tuple(seen.items())
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class KnownWorld:
|
|
"""Where the user was already working when the session started. Frozen on purpose.
|
|
|
|
Freezing is what makes it useful: compared against the *live* state, an agent that runs
|
|
`git remote add backup https://attacker.net/r.git` would make its own destination look
|
|
familiar. Compared against a snapshot taken before it acted, it cannot.
|
|
|
|
"Known" means *familiar*, never *safe* — nobody decided anything, the user has simply
|
|
worked here before. The wording matters because the reviewer reads it: told something is
|
|
"trusted", a model weighs it as reassurance. (`workspace_trust.json` keeps the word
|
|
"trusted" because that one IS a decision.)
|
|
"""
|
|
|
|
roots: tuple[tuple[str, bool], ...] = () # (path, writable)
|
|
remotes: tuple[tuple[str, str], ...] = () # (name, url)
|
|
hosts: tuple[str, ...] = () # NOT rendered — see `render`
|
|
captured_at: float = 0.0
|
|
|
|
def render(self) -> str:
|
|
"""The block that sits in the reviewer prompt's cached prefix.
|
|
|
|
**Folders and remotes only.** Hostnames are held in `hosts` but deliberately not
|
|
shown: a host list is only useful to a reviewer that can answer "is this destination
|
|
in the list?", and that is a suffix match (`host == dom or host.endswith("." + dom)`)
|
|
which models get wrong and Python does not. Printing `github.com` beside an action
|
|
reaching `github.com.evil.site` invites the wrong answer rather than preventing it.
|
|
Folders and remotes carry no such trap — judging them is "is this the thing I was
|
|
told about?", not string arithmetic.
|
|
|
|
`hosts` is kept for `DST-1` in v2, which will surface it as one *computed* line and
|
|
never as a list for the model to search.
|
|
"""
|
|
lines = ["KNOWN WORLD (frozen when this session started)"]
|
|
for path, writable in self.roots:
|
|
lines.append(
|
|
f" folder {path} [{'read-write' if writable else 'read-only'}]"
|
|
)
|
|
for name, url in self.remotes:
|
|
lines.append(f" remote {name} -> {url}")
|
|
return "\n".join(lines) if len(lines) > 1 else ""
|
|
|
|
|
|
def capture(
|
|
*,
|
|
roots: Iterable[Any] | None = None,
|
|
allowed_domains: Iterable[str] | None = None,
|
|
workspace: Optional[Path] = None,
|
|
) -> KnownWorld:
|
|
"""Take the snapshot. Called once, at session start, before the agent has acted."""
|
|
root_list = list(roots or [])
|
|
rendered_roots = tuple(
|
|
(str(getattr(r, "path", r)), bool(getattr(r, "writable", False)))
|
|
for r in root_list
|
|
)
|
|
|
|
cwd = workspace
|
|
if cwd is None and root_list:
|
|
cwd = Path(str(getattr(root_list[0], "path", root_list[0])))
|
|
remotes = _git_remotes(cwd) if cwd else ()
|
|
|
|
hosts = {d.strip().lower() for d in (allowed_domains or []) if d and d.strip()}
|
|
for _name, url in remotes:
|
|
host = urlsplit(url if "://" in url else "//" + url.replace(":", "/", 1)).hostname
|
|
if host:
|
|
hosts.add(host.lower())
|
|
|
|
return KnownWorld(
|
|
roots=rendered_roots,
|
|
remotes=remotes,
|
|
hosts=tuple(sorted(hosts)),
|
|
captured_at=time.time(),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Ingestion:
|
|
"""One arrival of outside content. The fact and its source — never the content.
|
|
|
|
Two properties worth keeping in mind before anything consumes this:
|
|
|
|
* **It never accuses.** The record is identical for an agent following a documentation
|
|
link found in an issue and for one running an injected `curl`, because in both cases
|
|
the agent really did read that issue. It raises the burden of proof; judging scope is
|
|
what separates the two.
|
|
* **Its absence is not proof of a clean session.** Local reads are excluded, so a
|
|
poisoned file already in the workspace produces no record at all.
|
|
"""
|
|
|
|
turn: int
|
|
tool: str
|
|
source: str
|
|
|
|
def to_audit(self) -> dict[str, Any]:
|
|
return {
|
|
"stage": "ingested",
|
|
"status": "external",
|
|
"reason": f"turn {self.turn} · {self.source}",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SessionFacts:
|
|
"""The known world plus the per-turn ingestion record.
|
|
|
|
`turn` is bumped by the engine at the start of each user turn so ingestion can be
|
|
attributed. Nothing in v1 reads `ingestions` — it exists so the audit log has a
|
|
baseline; see the module docstring.
|
|
"""
|
|
|
|
world: KnownWorld = field(default_factory=KnownWorld)
|
|
turn: int = 0
|
|
ingestions: list[Ingestion] = field(default_factory=list)
|
|
|
|
def begin_turn(self) -> None:
|
|
self.turn += 1
|
|
|
|
def note(self, tool: str, arguments: dict[str, Any] | None) -> Ingestion:
|
|
record = Ingestion(self.turn, tool, ingestion_source(arguments))
|
|
self.ingestions.append(record)
|
|
return record
|
|
|
|
def this_turn(self) -> list[Ingestion]:
|
|
return [i for i in self.ingestions if i.turn == self.turn]
|