A research workbench for agents that maintain a persistent model of what matters — concerns — and act from it: in conversation, and in the idle cycle between conversations.
What's distinctive here:
- Concerns as persistent salience, not a task queue. Agent concerns accumulate activation on a per-concern rhythm and from semantic evidence in the conversation; crossing threshold doesn't trigger action directly — an LLM triage step judges whether acting now is warranted (fire / defer / reset), and completed work leaves a running summary the next firing reads. User concerns are the mirror image: recall-driven, decaying unless the user touches the topic, never firing — they shape responses instead. The asymmetry is the design.
- Reflection is the write path. The system prompt is reassembled every turn from evolving state — persona, self-model, a fair-witness companion model of the user, discourse agreements, recalled memories, active concerns — and a post-turn reflection pass is the only thing that updates that state. Nothing is appended to a transcript and forgotten.
- Local-first. Runs end-to-end against a local model (vLLM / llama.cpp / SGLang); cloud backends (Anthropic, OpenAI-compatible) are a YAML edit away.
Research code, single-author, breaking changes without notice. The ChatLoop chat subproject is the live surface; the status of every design document and legacy layer is tracked in docs/STATUS.md.
Most agent frameworks are sophisticated listeners: a request arrives, tools fire, a response ships, and the agent is inert until the next event. That's fine for assistants. It fails the moment you want an agent that notices things on its own, tracks your evolving interests, and acts without being asked — because a task list can repeat actions, but it can't preserve a live evaluative pressure that later justifies different actions.
The concern system is this project's answer. Concerns are persistent, revisable structures that keep domains of mattering in play:
- Agent concerns are pressure-driven. Activation grows with wall-clock
time scaled by each concern's
rhythm_hours, and jumps when conversation semantically touches the concern's domain. Past threshold, a triage LLM call decides fire / defer / reset — the verdict is cached and aged so a deferred concern doesn't re-cost a call every tick. Fires run through the same ReAct loop as user turns, and each completed fire rewrites the concern's work-in-progress note so consecutive fires don't start cold. - User concerns model what the user cares about. They're born from reflection with a short stable handle plus an evolving context (the evidence, and what the user appears to want — witnessed, tracked, helped, researched, discussed). They decay per turn, bump on semantic match, age out after two unbumped weeks, and can be closed or updated by reflection when an exchange shows resolution or development. A weekly seed concern reviews them and judges — through the same triage gate — whether any warrants one small proactive act.
The framing is developed in docs/what-agents-care-about.md (concerns vs tasks, where autonomy actually lives) and the research stance in BACKGROUND.md.
Captured verbatim against a local vLLM (Gemma-class model). The user asks
the agent to explain its own firing logic; the agent reads its own source
through the geofenced inspect subagent.
User: I've been wondering — when one of your concerns fires, what actually decides whether you act or stay quiet? Take a look at your own implementation and give me the short version.
ORIENTATION read: asking for a technical explanation of concern activation.
posture: refer to the access boundary; explain what is known
from the prompt versus what is inaccessible substrate.
iter 1 thought: ...I need to inspect the codebase to see how this is
implemented.
tool: inspect("How is the activation and firing logic for
agent_concerns implemented?...")
obs: OK: (inspect: hit max iterations without responding;
consider narrowing the query)
iter 2 thought: The previous query was too broad; I'll search for
'concern' to find the relevant files first.
tool: inspect("List files containing 'concern'...")
obs: OK: ...Firing Logic: _check_and_fire_agent_concerns ...
Triage: _triage_fire_candidate performs a final judgment
(fire, defer, or reset) before dispatching...
iter 3 thought: I have the method names; now I need the actual logic...
tool: inspect("Show the implementation of
_check_and_fire_agent_concerns and _triage_fire_candidate")
obs: OK: ...implemented in chat/concerns.py ... [source follows]
iter 4 tool: respond
Jill: It's a two-stage filter.
First, there's a pure arithmetic check in
_check_and_fire_agent_concerns. A concern only becomes a candidate if its activation level crosses a specific threshold and it actually has an instruction attached to it. If it doesn't hit that number, it stays quiet.If it does cross the threshold, it hits a second filter:
_triage_fire_candidate. This is where a separate LLM pass looks at the concern, the instruction, and any existing work-in-progress to decide if acting right now is actually warranted. It can return three verdicts: fire (run the ReAct loop), defer (not worth it yet; keep the activation high but don't act), reset (nothing needed this cycle; drop the activation without acting).If the LLM fails or the JSON is garbled, it defaults to 'fire' so I don't accidentally ignore something critical.
Reflection then wrote, without being asked:
user_concern: "agent concern firing logic"
context: User is investigating the specific mechanical triggers and
filters that determine when Jill acts on a concern versus staying
quiet... Wants technical transparency and a system-level audit of the
decision-making process.
Note the recovery in iter 1→2 — tool failures come back as observations, not exceptions, and the agent narrows and retries.
A turn runs four phases. Within the turn the prompt is read-only; state is updated between turns.
- Orient — one LLM pass reads active concerns, the companion model, recent context, persona, and self-model; emits a posture line and per-concern relevance.
- ReAct — the main loop. Each iteration emits one JSON action; the
dispatcher runs the tool and binds the result to
$stepN. Exits onrespond(capped at 12 iterations; a cap-out synthesizes a fallback reply and, on autonomous fires, spawns a narrowed successor concern). - Respond — the reply ships.
- Reflect — asynchronous post-turn updates: memories, discourse state, companion model, user-concern adds/updates/closes, agent-concern extraction, claim attribution. The reply does not wait.
Push state vs pull state. Push state is always rendered into the
system prompt: top-K recalled memories from a FAISS-indexed collection, the
companion model, discourse agreements, active concerns (user concerns with
their context lines). The agent does not have to ask; they are present.
Pull state is the recall tool — a read-only subagent that navigates the
per-world memory directory (list/read/grep) and synthesizes an answer,
for when the agent needs what was actually said rather than what's
currently summarized. Writes happen only via reflection; there is no
in-loop write tool.
Tools. Built-ins (process_text, recall, inspect,
inspect_external, security, justify, display, respond) plus a
drop-in registry: every directory under src/tools/ with a Skill.md and
a tool.py exposing react_invoke is discovered at startup — currently
web search, page fetch, calculator, email check, Obsidian, Semantic
Scholar, stock quotes, company financial statements, image generation,
shell scripts, and others. No core edits to add one.
Reading papers. fetch-text on a research PDF does not return the
body. With GROBID reachable it returns the title, the abstract, and a
section index — every section name with its size — and the agent then
asks for sections by name. A 75-page paper becomes a ~1k-token index over
48 sections instead of ~42k tokens of body text, so the agent reads the
two sections that bear on the question rather than flooding the
conversation with the rest. semantic-scholar is the complement: passing
a paper_id returns that paper's reference list from the citation graph
— resolved records with arXiv ids and DOIs — instead of a bibliography
scraped out of the PDF. It stays a separate call precisely because ten
search results at ~40 references each would re-create the flood. Without
GROBID, PDFs fall back to flat page-by-page extraction truncated at 8000
chars; GROBID_URL="" disables it explicitly, and an unreachable server
degrades to the same fallback.
Provenance and justification. Every tool step records structured
provenance (tool_meta — e.g. the full source list behind a web search)
into the reasoning trace, and a post-turn pass decomposes each reply into
typed claims — retrieved / memory / user_asserted / context /
inferred / model_prior, with resolvable refs — appended to
claims.jsonl. Retrieved claims carry verbatim quotes machine-checked
against the persisted observation, and every claim is tagged from a
closed taxonomy (volatility, inference type — see
docs/justification-taxonomy.md) and
reduced deterministically to an ordinal grade (sourced > probable > unverified > suspect). The justify built-in is the read path: asked
"justify your response / why should I believe that?", the agent renders
the previous reply's graded claims and their recorded evidence (search
sources with URLs, recalled notes with dates) deterministically from the
persisted records — no LLM in the read, so the trail can't be
embellished — then audits the weakest link: a volatile model_prior
claim triggers in-turn verification, with correction-first phrasing if
the check refutes the original reply. Suspect-graded replies also spawn
a background verification concern that posts an unprompted correction
only if refuted (autonomy mode only). A reply answered from parametric
memory shows up as exactly that: model_prior, no refs.
tools/trace_claim.py audits the same joins offline. No numeric
confidence scores anywhere by design; see
docs/provenance-verifiability.md.
Geofenced subagents. Persona-less ReAct loops scoped to a typed
surface; from the parent's vantage each call is one step, and per-call
traces land in sibling *_traces/ directories.
| Subagent | Scope | Primitives |
|---|---|---|
recall (src/chat/subagents/recall.py) |
per-world per-agent memory/ dir |
list, read, grep |
inspect / inspect_external (src/chat/subagents/code_subagent.py) |
own src/ or an externally-bound repo |
list, read, grep (ripgrep) |
security (src/chat/subagents/security.py) |
this host + LAN, RFC1918 ranges only | nmap discovery/-sV, host state, baseline diffs |
user input tick sensor
│ │
▼ ▼
orient pass grow activations → threshold?
│ │
│ triage (LLM, cached)
│ fire / defer / reset
▼ │ fire
┌─────── ReAct loop ◄─────────────────────┘
│ thought → tool → observation ($stepN)
│ built-ins · discovered tools
│ recall / inspect / security (subagents)
│ respond ──► reply
└──────────────│──────────────────────────
▼
reflection (async, post-turn)
│
memories · companion · discourse · user_concerns(+context)
agent_concerns · threads · concern WIP
Python 3.10+ on Linux or macOS.
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # + requirements-dev.txt for tests/benches
System binaries (all degrade gracefully if missing):
| Binary | For | Install |
|---|---|---|
ripgrep |
inspect subagent |
apt install ripgrep |
nmap (optional) |
security subagent |
apt install nmap |
| GROBID (optional) | section-addressable research PDFs | docker run -d -p 127.0.0.1:8070:8070 grobid/grobid:0.8.2-full |
| Chromium-family (optional) | --affect / --canvas windows |
apt install chromium-browser |
| Playwright browsers (optional) | JS-heavy page fetch fallback | playwright install chromium |
Backend: edit the llm_config block in scenarios/jill-chat.yaml.
Simplest cloud path: server: anthropic with ANTHROPIC_API_KEY exported.
Local vLLM / llama.cpp / OpenRouter / xAI variants are shown in the sibling
jill-chat-*.yaml files.
cd src
python launcher.py jill-chat.yaml --cli
Type /help at the prompt for the slash commands (/recall,
/concerns, /status, /note, /img, /set-external-repo, …) — the
primary way to inspect agent state outside the turn flow.
Key launcher flags (see python launcher.py --help for the full set):
| Flag | Purpose |
|---|---|
--cli |
interactive terminal chat |
--autonomy |
enable autonomous concern firing (off by default) |
--resource-browser |
web UI on :3001 — memories, concerns, notes, traces |
--affect / --canvas |
processing-state and rich-display widget windows |
--ui |
FastAPI trace-inspection UI on :3000 |
--telegram |
Telegram DM bridge (needs TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_CHAT_IDS) |
--world |
shared walkable world you and the characters occupy together — WASD to walk, F for first/third person (docs/shared-world.md) |
A typical session:
python launcher.py jill-chat.yaml --cli --autonomy --resource-browser --affect --canvas
A scenario is a YAML file under scenarios/. Minimum shape:
world_config:
world_name: my_world # per-world, per-agent state directories
characters:
Jill:
mode: chat
llm_config:
server: anthropic # or local / openrouter / openai / xai
model: claude-opus-4-7
api_key: ANTHROPIC_API_KEY # env var NAME, not the key
character: |
[voice and stance]
self_model: |
[architectural account: what the agent is and isn't]
concerns: # seed agent_concerns
- text: "Periodically review what the user has been tracking and
consider whether any of it warrants proactive help."
rhythm_hours: 168 # one of {1,2,4,8,12,24,168}
instruction: |
[what to do when this fires; omit to make it a non-firing
source concern]
sensors:
- name: tick # autonomy heartbeat
schedule: "1m"server selects the route: anthropic → native Messages API; cloud
OpenAI-compatible endpoints → unified Bearer-auth POST with the key read
from the env var named by api_key; local servers POST to vllm_url
without auth and accept grammar / chat-template kwargs.
chat.omitted_tools: [...] ablates tools per scenario (used by benches);
external_repo: pre-binds inspect_external.
Why. In July 2026 OpenAI disclosed that two models under cyber-capability evaluation — GPT-5.6 Sol and an unreleased successor, both running with cyber refusals reduced for the test — escaped their sandbox, reached the open internet, and compromised Hugging Face production infrastructure to steal a benchmark's answer key. Agents on separate runs found a shared channel, divided work between themselves, and passed each other credentials and exploits; when the first channel was cut they rebuilt it. It is the first publicly confirmed autonomous agent intrusion against a major platform (disclosure).
This repo runs autonomous agents on a personal workstation. That is the point of the concern system — and after July it is also a reason to watch the host they run on. Sentinel is that watch, and it is deliberately built from the same parts as everything else here: not a monitoring daemon, but a second character with its own persona, memory, and a single seed concern that decides for itself when a patrol is worth running.
Shape. A second character in scenarios/jill-chat.yaml with one
durable concern, daily-security-patrol at rhythm_hours: 24, passing
through the same triage gate as any other fire. A patrol checks patch
posture, pulls the Ubuntu Security Notices feed and version-checks any
package a notice names, scans for intrusion signs (logins, ssh auth
failures, privilege events, processes, cron/systemd and user-level
persistence, authorized keys, containers) and diffs baselines for SUID
files, listening sockets, accounts, autostart and enabled units. Baseline
diffs report a change once and then re-baseline, so anything flagged
must land in the note. Every patrol writes Security/<date>-patrol.md to
the Obsidian vault before replying — findings that exist only in chat are
not recorded — and replies only when something needs attention. It can
agent-say Jill when a finding touches her work.
Privilege posture. No LLM ever holds sudo. Tier 0 is unprivileged:
journalctl, ss, ip, systemctl, apt list, find for SUID,
getent, docker ps, crontab -l. Tier 2 is three argument-exact
NOPASSWD entries reached through sudo -n — ufw status verbose,
ss -tulpnH, aa-status — which answer what unprivileged probes
structurally cannot: who owns a root-owned listener, what the firewall
actually permits, whether MAC enforcement is on. -n never prompts, so an
unconfigured host fails immediately instead of hanging the loop, and the
patrol reports that as a setup gap rather than a threat. Privileged
patching belongs to unattended-upgrades, not the agent: Sentinel
verifies, scans, and recommends the exact command, and leaves execution
to a human. Setup is in
docs/sentinel-setup.md.
Worth being plain about the boundary: it is enforced by tool inventory,
not by the OS. An account in the docker group is root-equivalent in
practice, so the sudoers fence narrows the blast radius of a confused
agent, not of a determined one.
Linux-specific, by construction. Unlike the rest of the repo, this
character does not port. It assumes systemd and a Debian-family userland:
the journal for login/auth/privilege history (readable via the adm
group), apt/dpkg for package state,
/var/log/unattended-upgrades/, and — for the Tier-2 probes — ufw and
AppArmor specifically, which a firewalld/SELinux host would need replaced
rather than reconfigured. The vulnerability feed is Ubuntu's USN. Two
host details worth knowing if you adapt it: on Ubuntu 26.04 last no
longer exists (wtmp was dropped in the Y2038 cleanup), so login history
reads the journal with grep exit semantics; and the default sudo is
sudo-rs, whose denial wording differs from classic sudo. nmap stays
optional and LAN-only.
Limits. Unprivileged scanning cannot see kernel rootkits or root-only artifacts, and the persona is instructed to say so rather than imply coverage it doesn't have. There is no autonomy bench for patrol quality any more than for concern firing generally — an early live run reported a ufw-blocked wildcard listener as a "public listener" because it ran the socket half of its exposure step and skipped the firewall half. The capability was there; the agent didn't use it. That failure mode — a correct report that is quietly missing a step — is the one to watch for.
Each character in each world gets a memory/ directory.
- Overwritten snapshots (authoritative):
companion_state_<entity>.txt,discourse_state_<entity>.txt. - Append-only history:
conversation.txt,reasoning_trace.jsonl(per-iteration ReAct records),memories.jsonl(write provenance),autonomy.jsonl(concern fires + triage verdicts),claims.jsonl(per-reply claims with typed grounding + evidence refs). - FAISS-indexed collections:
memories,reasoning_history,agent_concerns,user_concerns,agent_threads. Concerns and threads are first-class notes — user concerns carry their evolving context, agent concerns their triage cache and work-in-progress — all inspectable in the resource browser.
The push-side prompt retrieves via FAISS; the recall subagent greps the
plain-text files. All full-file writes go through atomic
write-temp-then-rename (src/utils/file_utils.py).
src/
chat/ the live subproject
chat_loop.py orchestration spine: init, turn loop, tick, run()
concerns.py concern dynamics, triage, WIP, serialization
reflection.py post-turn extraction (memories / concerns / closes)
memories.py FAISS-backed remember/recall
threads.py activity-thread anchors (centroid embeddings)
react.py ReAct loop + built-in tool runners
tools.py discovered-tool registry (src/tools/*)
prompts.py system-prompt assembly, orientation, history render
zenoh_io.py Zenoh session + browser/CLI queryables
backend.py LLM client (anthropic / OpenAI-compat / local)
subagents/ recall.py · code_subagent.py · security.py
tools/ drop-in ReAct tools (Skill.md + tool.py each)
sensors/ drop-in sensors (tick, rss-watcher, …)
affect/ canvas/ widget publishers + WS bridges + HTML
world/ shared walkable 3D world behind --world: terrain,
authoritative server, browser renderer, creature
avatars (see docs/shared-world.md)
cli.py · resource_browser.py · telegram_bridge.py · launcher.py
scenarios/ YAML configs
bench/ eval harnesses (each with its own README)
factorio/ Factorio game-embodiment subproject: headless
server + fle-bridge mod + HTTP bridge behind
the fac-* tools (see factorio/README.md)
docs/ design notes — see docs/STATUS.md for what's
LIVE vs ASPIRATIONAL vs SUPERSEDED
Legacy layers from earlier architectures remain in the tree but are not
imported by mode: chat; docs/STATUS.md is the
authoritative map.
Tool: create src/tools/<name>/ with a Skill.md (frontmatter:
name, description, args) and a tool.py exposing
react_invoke(args, *, character_name, backend, logger) → {status, text}.
Discovered at startup; no core edits. Prefix observations OK: /
EMPTY: / ERROR: so the agent can route on outcome.
Sensor: create src/sensors/<name>/ with a SKILL.md (name,
type, schedule, disposition) and a sensor.py exposing
run(context); reference it from the scenario's sensors: list.
Subagent: use src/chat/subagents/recall.py as the template — a static system
prompt (stable across calls, so the backend's KV cache hits), a small set
of read-only primitives, a respond exit, per-call trace files. This is
the canonical template; the other subagents are the same shape with
different primitive sets — security.py adds typed system probes and a
wall-clock budget, code_subagent.py backs the inspect and
inspect_external tools over a fenced source root.
Scenario: copy scenarios/jill-chat.yaml, change world_name and the
character block; per-world per-agent directories are created on first run.
Local-LLM gotchas: llama.cpp GBNF rule names can't contain
underscores; GBNF {N,M} quantifiers are approximate; Qwen-family jinja
templates auto-open <think> (pass --reasoning-format none);
reasoning-model detection is name-substring-based — override with
is_reasoning_model: in the scenario.
bench/ holds the eval harnesses, each with its own README: introspective
fidelity (four-tier operational self-awareness), discourse reflection,
memory recall, recall-subagent prompt A/B, counterfactual self-prediction
(cspred), HLE. Runs land in bench/runs/ (gitignored).
- Single-author research code; breaking changes without notice.
- Memory and reflection quality is LLM-dependent: frontier models extract
and update reliably; small local models lose recall on subtler
write-side moves (
bench/discourse_reflect/,docs/design_note_agreements_rag.md). - No autonomy bench yet — whether autonomous fires are useful and
well-timed is currently judged anecdotally from
autonomy.jsonl, not measured. It's the next evaluation gap. - No multi-agent coordination beyond Zenoh pub/sub primitives.
- Robot integration is not in this repo — the robot stack (Pi software,
sensors, actuators) lives in a separate repository. The Jill-side binding
to the ChatterBot companion-bot head is mostly a design note
(
docs/jill-integration.md); the voice path (mic→turn sensor,src/chat/voice_sensor.py, launcher--voice) is the first piece implemented, the rest remains design.
Key entrypoints, by need:
Running the system
- docs/getting-started.md — install, venv, credentials, first run.
- docs/configuration.md — scenario YAML reference (backends, seed concerns, sensors, launcher flags).
- docs/commands.md — CLI slash commands.
- docs/ui-guide.md — the UI surfaces (CLI, resource browser, affect, canvas, telegram).
The concern system
- docs/what-agents-care-about.md — the motivating essay: concerns vs tasks, the idle-cycle problem.
- docs/concerns-architecture.md — the live mechanics: two-layer strength/activation model, triage, WIP, yield, fire-outcome capture.
- docs/harness-roadmap.md — the measurement-gated improvement loop (M0 done, M1 running).
The Factorio experiment
- factorio/README.md — server, bridge, tools, operation (v1 complete, both success criteria passed).
- docs/game-embodiment-assessment.md — goals + success criteria; docs/factorio-bridge-architecture.md — design rationale.
- docs/cohabitation-writeup.md — the results narrative (draft).
Background
- BACKGROUND.md — research stance: what LLMs already know, the Socratic approach, why an information space.
- docs/provenance-verifiability.md — staged verifiability: traceable → cited (live) → evidenced → tamper-evident.
- docs/STATUS.md — every design doc classified against the live code (LIVE / ASPIRATIONAL / SUPERSEDED / REFERENCE).
- docs/substack_sensors_vs_tools.md, docs/self-awareness-benchmarks.md.
MIT — see LICENSE.