Skip to content

Repository files navigation

Cognitive Workbench

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.

The idle-cycle problem

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.

One real turn

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.

Anatomy of a turn

A turn runs four phases. Within the turn the prompt is read-only; state is updated between turns.

  1. Orient — one LLM pass reads active concerns, the companion model, recent context, persona, and self-model; emits a posture line and per-concern relevance.
  2. ReAct — the main loop. Each iteration emits one JSON action; the dispatcher runs the tool and binds the result to $stepN. Exits on respond (capped at 12 iterations; a cap-out synthesizes a fallback reply and, on autonomous fires, spawns a narrowed successor concern).
  3. Respond — the reply ships.
  4. 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

Quick start

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

Configuration

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.

Sentinel: a second character

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 -nufw 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.

Memory substrate

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).

Project layout

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.

Extending

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.

Benchmarks

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).

Limitations and non-goals

  • 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.

Reading

Key entrypoints, by need:

Running the system

The concern system

The Factorio experiment

Background

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages