Skip to content
Closed
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
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,21 @@ updates:
update-types:
- "minor"
- "patch"

# gitsubmodule is the other exception: this fork carries the
# `tinker-atropos` submodule, which is a vendored pin to a specific
# upstream commit. Dependabot opens a PR when that upstream advances
# so we can update the pin deliberately, on review — same model as
# github-actions above. Monthly cadence is intentional: submodule
# bumps are heavier-weight than action SHA bumps and don't need a
# weekly rhythm.
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "monthly"
labels:
- "dependencies"
- "submodule"
commit-message:
prefix: "chore(submodule)"
include: "scope"
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,69 @@ in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
- `error` — only the final message when exit code != 0
- `off` — no watcher messages at all

### Optional extras and submodules — security boundaries

A subset of opt-in extras and the `tinker-atropos` git submodule pull research
code from upstream repositories that **lack a LICENSE file** or are otherwise
unsuitable for production. These are deliberately fenced behind opt-in
extras / uninitialized submodules, and the boundary is load-bearing: if it is
crossed by accident (e.g. a deploy script that runs `pip install -e ".[all,yc-bench]"`),
unlicensed code lands in product runtime. The 2026-05-05 dependency audit
(`CHG-0001`) flagged this as the cheapest mitigation for two latent CRITICAL
findings.

**Research-only — must NOT appear in any orchestrator default-install path,
deployment image, or CI workflow that runs in production:**

| Item | Source | License | Notes |
|---|---|---|---|
| `[rl]` extra | `NousResearch/atropos` (MIT) + `thinking-machines-lab/tinker` (Apache-2.0) | OK individually | RL training stack — research-only, heavy GPU deps (`wandb`, `fastapi`). Not for product runtime. |
| `[yc-bench]` extra | `collinear-ai/yc-bench` @ `bfb0c88` | **NO LICENSE** (CRIT-2) | No grant of use. Pulling this into a deploy is an unlicensed-code incident. |
| `tinker-atropos` submodule | `nousresearch/tinker-atropos` | **NO LICENSE** (CRIT-1) | Registered in `.gitmodules`, intentionally **uninitialized**. Do NOT add `git submodule update --init` to any deploy/CI step. |

**The operating rule:**

1. The default install — `uv pip install .` (or `pip install -e .`, or
`uv pip install '.[all]'`) — does NOT pull `[rl]` or `[yc-bench]`. The
`[all]` meta-extra deliberately omits both. Keep it that way.
2. The `tinker-atropos` submodule must NOT be initialized in any
deployment-time path. `git clone` without `--recurse-submodules`, and
leave it that way.
3. Opting in is a deliberate research-mode choice. To enable the RL stack
locally for research, run `uv pip install '.[rl]'` (and `'.[yc-bench]'`
separately on Python ≥ 3.12). Doing so is an explicit acceptance of the
unlicensed-code risk for `yc-bench` / `tinker-atropos` and means the
environment is for personal research only — never a shared image, never
a deploy target.
4. Orchestrators that consume `hermes-agent-ucpm` (e.g. `paperclip-UCPM-orchestrator`)
MUST NOT add `[rl]` or `[yc-bench]` to their default-install manifest, and
MUST NOT initialize the `tinker-atropos` submodule in their build steps.

**Maintainer check — verify defaults are clean:**

```bash
# 1. Confirm [rl] and [yc-bench] are in [project.optional-dependencies] only,
# NOT in [project.dependencies] or [all]:
grep -nE '^(rl|yc-bench)\s*=' pyproject.toml # must be under [project.optional-dependencies]
grep -nE 'hermes-agent\[(rl|yc-bench)\]' pyproject.toml # must return nothing (i.e. not in [all])

# 2. Confirm tinker-atropos is registered but uninitialized in fresh clones:
git config --file .gitmodules --get submodule.tinker-atropos.url # registered → URL prints
ls tinker-atropos 2>/dev/null && echo "WARNING: submodule populated" || echo "OK: not initialized"

# 3. Confirm the default install resolves without research deps:
uv pip install --dry-run . 2>&1 | grep -E '(yc-bench|atroposlib|tinker[^-])' \
&& echo "FAIL: research dep in default tree" || echo "OK: default tree clean"
```

If any of those checks flag a regression, the security boundary has been
breached — fix `pyproject.toml` / `.gitmodules` before merging.

**Aside — `[messaging]`, `[matrix]`, `[homeassistant]`, `[sms]`:** these extras
pull `aiohttp@3.13.3`, which has 10 known CVEs (3 HIGH per audit CRIT-5).
Orchestrators layering these in should pin a `aiohttp>=3.14` floor in their
own manifest until the upstream pin is bumped.

---

## Profiles: Multi-Instance Support
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE_CODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Claude Code Worktree

This directory is a Claude Code worktree for `hermes-agent-ucpm`.

- **Branch**: `chore/dependabot-gitsubmodule-CHG-0001`
- **Scope**: CHG-0001 — append `gitsubmodule` ecosystem block to `.github/dependabot.yml`.
- **Upstream policy preserved**: the existing `github-actions` block (and the comment block explaining why pip is intentionally excluded under uv.lock pinning) is untouched. Only the new `gitsubmodule` ecosystem is added.
- **Why a fork-only addition**: this fork carries the `tinker-atropos` submodule that upstream's policy doesn't cover.
- **Orchestrator issue**: https://github.com/WanderingStardust79/paperclip-UCPM-orchestrator/issues/2

Do not merge from this worktree directly — review and merge via PR on GitHub.
10 changes: 10 additions & 0 deletions hermes_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""UCPM-specific code for the hermes agent runtime.

This package is namespaced separately from the upstream `hermes`/`hermes_cli`
modules so it can be carried through upstream rebases without conflicts.
Everything UCPM-specific (per-property loops, SOP-driven procedures, the
agent personas defined in `paperclip-UCPM/companies/ucpm-default/SOP.md`)
lives here.
"""

__version__ = "0.1.0"
6 changes: 6 additions & 0 deletions hermes_agent/loops/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Per-property agent loops.

Each loop is a small, file-driven runtime that exercises a subset of the
UCPM SOP procedures end-to-end without depending on Postgres, IMAP/SMTP,
or external services. Real I/O is wired in later.
"""
95 changes: 95 additions & 0 deletions hermes_agent/loops/audit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Audit log writer for the file-based test property loop.

One JSONL row per procedure step, schema mirrors `ucpm.audit_log` from the
canonical SOP global invariants:
ts, property_id, procedure_id, step, persona, inputs_hash,
action, output_ref, escalated_bool, operator_review_state.

The on-disk shape is stable so a future BigQuery loader can ingest these
files unchanged.
"""

from __future__ import annotations

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

from .schemas import AuditRow


def compute_inputs_hash(payload: Any) -> str:
"""Deterministic content hash of an arbitrary JSON-serializable payload.

Used in `audit_row.inputs_hash` so replays are detectable and the
BigQuery `audit_log` table can dedupe by content.
"""
serialized = json.dumps(payload, sort_keys=True, default=str, separators=(",", ":"))
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()


class AuditWriter:
"""Append-only JSONL writer scoped to a single inbound message id.

Each call to `write()` adds one line to `<audit_dir>/<msg_id>.jsonl`.
The writer never reads or rewrites prior lines — appending only.
"""

def __init__(self, audit_dir: Path, msg_id: str, property_id: str):
self.audit_dir = audit_dir
self.msg_id = msg_id
self.property_id = property_id
audit_dir.mkdir(parents=True, exist_ok=True)
self._path = audit_dir / f"{msg_id}.jsonl"

@property
def path(self) -> Path:
return self._path

def write(
self,
*,
procedure_id: str,
step: str,
persona: str,
inputs_hash: str,
action: str,
output_ref: Optional[str] = None,
escalated: bool = False,
operator_review_state: str = "n/a",
decision_criteria: Optional[dict[str, Any]] = None,
notes: str = "",
) -> AuditRow:
row = AuditRow(
ts=datetime.now(timezone.utc),
property_id=self.property_id,
procedure_id=procedure_id,
step=step,
persona=persona,
inputs_hash=inputs_hash,
action=action,
output_ref=output_ref,
escalated_bool=escalated,
operator_review_state=operator_review_state, # type: ignore[arg-type]
decision_criteria=decision_criteria or {},
notes=notes,
)
# `mode_json` style ordered dict so on-disk fields read in audit-row
# order (BigQuery-friendly).
line = row.model_dump(mode="json")
with self._path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(line, separators=(",", ":")))
fh.write("\n")
return row

def read_all(self) -> list[dict[str, Any]]:
"""Helper for tests: read back what we've written."""
if not self._path.is_file():
return []
rows = []
for line in self._path.read_text(encoding="utf-8").splitlines():
if line.strip():
rows.append(json.loads(line))
return rows
123 changes: 123 additions & 0 deletions hermes_agent/loops/classifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""P-01 — Inbound tenant comm intake.

Classifies an inbound message into exactly one of the eight intents
defined in SOP P-01. The SOP is a deterministic first-match-wins rule
list; we delegate the matching to the model with the SOP itself in cache,
because keyword matching alone fails on polite/indirect tenant phrasing.

The model returns a JSON object matching `Classification`. We validate
shape; if validation fails we fall back to `intent=unclassified` so the
loop continues and P-09 picks up the escalation downstream.
"""

from __future__ import annotations

import json
import logging
from typing import Any

from .llm_client import LlmClient
from .schemas import Classification, InboundMessage

logger = logging.getLogger(__name__)


P01_INSTRUCTION = """\
You are the `property-orchestrator` persona. Apply procedure **P-01 —
Inbound tenant comm intake** from the SOP above.

Task: classify the incoming tenant message into exactly ONE intent using
the SOP's first-match-wins decision rules:

1. maintenance — habitability/repair issues
2. payment — rent, balance, autopay, receipts
3. lease_change — renew, extend, terminate, move-out, sublease, alter
4. notice_required — formal notice (e.g. intent to vacate)
5. complaint_neighbor — noise, parking, smoke (cannabis context), neighbors
6. admin — COI, keys, parking permits, contact updates
7. legal — attorney, court, ADA, fair housing, statute citations
8. unclassified — none of the above

Also: identify the tenant if possible. The company state and tenant
records are in the system context above. Use the sender email or body
content to match a tenant; produce a short slug (e.g. "beautiful-minds-a-101")
if confident, else null.

Respond with ONLY a JSON object, no prose, no code fences:

{
"intent": "<one of the eight>",
"tenant_slug": "<slug or null>",
"confidence": <float 0.0-1.0>,
"rationale": "<one short sentence>"
}
"""


def classify(
message: InboundMessage,
*,
sop_text: str,
company_context: str,
llm: LlmClient,
) -> Classification:
"""Run P-01 classification on a single inbound message.

Args:
message: parsed inbox message.
sop_text: full SOP markdown — cached system block.
company_context: rendered per-property context — cached system block.
llm: shared LLM client (one instance reused across the inbox so
prompt cache hits across messages).

Returns:
Classification. On validation failure, falls back to
`intent=unclassified` and surfaces the parse error in the rationale.
"""
user_payload = json.dumps(_message_payload(message), separators=(",", ":"))

parsed: dict[str, Any]
try:
parsed, _call = llm.call_json(
cached_context_blocks=[sop_text, company_context],
instruction=P01_INSTRUCTION,
user_payload=user_payload,
max_tokens=512,
)
except Exception as exc: # noqa: BLE001 — never let a bad LLM call break the loop
logger.exception("P-01 LLM call failed for message %s", message.id)
return Classification(
intent="unclassified",
tenant_slug=None,
confidence=0.0,
rationale=f"classifier-error: {type(exc).__name__}: {exc!s}"[:240],
)

try:
return Classification(**parsed)
except Exception as exc: # noqa: BLE001 — schema mismatch
logger.warning(
"P-01 returned unparseable JSON for %s: %r — defaulting to unclassified",
message.id,
parsed,
)
return Classification(
intent="unclassified",
tenant_slug=None,
confidence=0.0,
rationale=f"schema-error: {type(exc).__name__}: {exc!s}"[:240],
)


def _message_payload(message: InboundMessage) -> dict[str, Any]:
"""Trim attachments to references — never feed raw blobs to the model."""
return {
"id": message.id,
"received_at": message.received_at.isoformat(),
"channel": message.channel,
"from": message.from_,
"to": message.to,
"subject": message.subject,
"body": message.body,
"attachment_count": len(message.attachments),
}
Loading