Vancetope — Trillian Engine
Trillian is Vancetope’s Agentic User Loop — a layer above Arthur/Eddie/Marvin/Frankie. From Trillian’s perspective, these are the tools a human user works with in Vancetope — Trillian uses them in the same way, just without a human in front. Observe-Think-Act-Reflect as an endless loop that sleeps when there’s nothing to do.
Trillian is not a Worker (Frankie is a Worker), not a Plan Engine (Marvin / Vogon), not a Chat Host (Arthur / Eddie). Trillian is the human’s counterpart — a user proxy with its own identity, its own Session, and its own Permissions.
Naming Note: In the Adams universe, Trillian is the rational, observant voice of the Heart-of-Gold crew — the only one who doesn’t lose sight of the overarching goal while Zaphod & Ford cause chaos.
1. Role and Classification
| Engine | Character | Owner | Termination |
|---|---|---|---|
arthur |
Reactive Session-Chat-Hub | Human | never DONE — STOPPED/SUSPENDED |
eddie |
Tenant-Hub, Cross-Project-Coordinator | Human | never DONE |
frankie |
Multi-Turn-Worker, terminate-driven | spawning user | DONE per Task |
trillian-control |
Reply-style Chat-Host, without Action Schema | Human | never DONE |
trillian-user |
Endless-but-sleepy Orchestrator, cross-project | _trillian-* Service-Account |
never DONE |
Use Cases (Vision, actually usable from Nature-A+):
- PR-Review-Watchdog (Trillian observes GitHub, comments)
- Daily Briefing from multiple sources
- CI/CD Observer with autonomous reaction
- Cross-Project Mail Triage (Mail with PDF → move to target project workspace)
- Long research assignments running for days
2. Architecture: Engine Framework + Nature Behavior
Trillian will evolve structurally. Instead of bending each
generation into the Engine, the Behavior layer
resides in interchangeable TrillianNature implementations — the Engine
itself only knows the interface.
┌─────────────────────────────────────────────────────────────┐
│ TrillianControlEngine / TrillianUserEngine (Framework) │
│ ────────────────────────────────────────────────────────── │
│ • Loop mechanics (drainPending, LLM-Call, Tool-Dispatch) │
│ • Inbox persistence, ChatLog │
│ • Cross-Project-Spawn-Routing │
│ │
│ │ per Turn: natureRegistry.resolve( │
│ ▼ engineParams.nature) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ TrillianNature (Interface) │ │
│ │ • id(), title() │ │
│ │ • controlPromptAddendum / userPromptAddendum │ │
│ │ • beforeControlTurn / afterControlTurn │ │
│ │ • beforeUserTurn / afterUserTurn │ │
│ │ • userLoopMayTerminate() │ │
│ └──────────────────────────────────────────────────┘ │
│ ▲ │
│ │ implemented by │
│ ┌──────┴────────────────┐ │
│ │ TrillianNature0 │ Nature-0: all defaults │
│ │ TrillianNatureA … │ Nature-A: personality, … │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
To create a new Nature = a Spring @Component with the correct
id() plus override of the relevant hooks. The TrillianNatureRegistry
indexes on boot. Recipes pin via params.nature: '<id>'.
Nature Versioning:
trillian-0— Architecture spike. Current implementation. Proves two-session mechanics, cross-project spawn, true identity separation. No personality, no reflection, no persistence — all Nature hooks at defaults.trillian-a(Nature-A, coming) — persistent Trillians with their own home project, simple reflection after task completion.trillian-b… — Personality, Traits, Mode-Switch, Token- Budget.
Test Natures get digit IDs (0-9); Production Natures
letter IDs (A-Z).
Recipe Convention:
| Recipe | Meaning |
|---|---|
trillian |
Alias to current default Nature (today Nature-0) |
trillian-0 |
Pinned Nature-0 (survives default change) |
trillian-a |
Pinned Nature-A (coming) |
trillian-user-<n> |
User-Loop Recipe per Nature |
trillian-worker-<n> |
Per-Task Worker per Nature |
3. Two-Session Architecture
Tenant: acme / Project: <Human's current project>
Session 1 — Control
Owner: Human (Session-Owner)
Profile: foot / web (bound connection)
Engine: trillian-control
Primary process: 'chat'
Tools: task_enqueue + user_* control-tools
│ task_request ProcessEvent (cross-session)
▼
Session 2 — Trillian-User (system=true, headless)
Owner: _trillian-0XXXX (Service-Account)
Engine: trillian-user
Primary process: 'trillian-user-loop'
Tools: project_list, process_create, cross_process_create,
process_steer, process_status, process_history_text,
peer_read_chat_memory, task_complete/failed/needs_input
│ cross_process_create(projectId=X, recipe=trillian-worker-0)
▼
Worker-Process — per Task, in the Trillian-User-Session but with
process.projectId = <Target-Project>
Owner: _trillian-0XXXX
Engine: frankie (via trillian-worker-0 recipe)
Tools: full Worker toolset (doc_*, file_*, exec_*) +
trillian_done for Termination
Why two Sessions:
| Aspect | Sibling-Processes (discarded) | Two Sessions (Nature-0) |
|---|---|---|
| Identity at runtime | userId from Session = Human — _trillian-* is phantom |
Trillian-User runs as _trillian-* (own Session-Owner) |
| Tool-Surface | Trillian inherits foot-Connection from human → can use client_* directly |
Trillian-User-Session is headless → client_* is structurally missing |
| Chat-Pollution | Worker-Replies leak into Human-Chat | Own Session = own Chat-Container |
| Permissions/Audit | wrong identity, wrong writer | correct identity, correct audit trail |
4. Engine Classes
4.1 TrillianControlEngine
- Loop: reply-style — drainPending → LLM-Turn → optionally Tool-Calls → natural-stop → IDLE. Wakeup on User-Input OR incoming task-event ProcessEvents.
- No structured-action-schema (unlike Arthur/Eddie). Tools
selected directly by LLM; saves tokens + prevents the DELEGATE-
funnel trap (Arthur’s structured “DELEGATE” action type
forced the LLM into
process_createindirection, which is semantically incorrect for Trillian). - Engine-Role:
trillian-control— gates the Control Tools (task_enqueue,user_*). - Model-Default:
default:analyze,default:fast— Analyze-Tier primarily because Gemini-Flash occasionally delivers finish=STOP with output=null for ambiguous tool sets. - Single-Retry-on-empty in the loop logic against the Gemini quirk.
4.2 TrillianUserEngine
- Loop: endless-but-sleepy, Frankie-like pattern. drainPending →
LLM → Tools → repeat → natural-stop = IDLE. No
_terminate, no wallclock/idle-stuck safety nets (Orchestrator, not Worker). allowsCrossProjectSpawn=true— Trillian-User can spawn Workers in foreign projects viacross_process_create.asyncSteer=true— Trillian-Control does not wait synchronously duringtask_enqueuedispatch.- Engine-Role:
trillian-user. - Model-Default:
default:analyze,default:fast.
Memory-Context and Compaction. Both Trillian Engines (Control + User) use the same MemoryContextLoader + MemoryCompactionService path as Arthur/Eddie/Ford/Frankie: buildPromptMessages appends composeBlock(...) (Languages, Agent-Doc, ARCHIVED_CHAT-Summary, RAG-Auto-Inject) to the System-Prompt; runTurn calls compactIfNeeded(...) before the first LLM-Call and rebuilds the Prompt if compacted()=true. Otherwise, the Nature-0 User-Loop under the _trillian-0XXXX service account would run autonomously over many turns and exceed the context window / trigger bill shock. See planning/memory-compaction.md §7 and memory-knowledge-management.md §10.
4.3 Worker-Engine: Frankie with trillian-worker-Recipe
- Frankie as the Engine. Own Recipe
trillian-worker-<n>with:- Full doc/file/exec Tool-Surface
trillian_done(summary, data?)Tool as mandatory termination (see §6)- Prompt discipline: “always call
trillian_doneat task end, never natural-stop”
- Worker has
parentProcessId = trillian-user-loop.id— DONE event flows back to Trillian-User viaParentNotificationListener.
5. Task-Lifecycle
Human Control Trillian-User Worker
│ "task X" │ │ │
├─────────────────────►│ │ │
│ │ task_enqueue(desc) │ │
│ ├──────────────────────►│ task_request event │
│ "Queued (taskId=…)" │ │ │
│◄─────────────────────┤ │ │
│ │ │ cross_process_create │
│ │ ├──────────────────────►│ spawn
│ │ │ │ doc_list (in X)
│ │ │ │ trillian_done(summary)
│ │ │ │ → CLOSED (DONE)
│ │ │ DONE event with │
│ │ │ enriched summary │
│ │ │◄──────────────────────┤
│ │ │ │
│ │ │ task_complete( │
│ │ │ taskId, result) │
│ │ task_done event │ │
│ │◄──────────────────────┤ │
│ "Done — N Docs." │ │ │
│◄─────────────────────┤ │ │
Routing between Sessions is Vancetope standard: EngineMessageRouter
dispatches by-processId, transparently crossing Session/Pod boundaries.
6. Trillian-specific Tools
| Tool | Role-Gate | Caller | Purpose |
|---|---|---|---|
task_enqueue(description) |
trillian-control |
Control | Push task to User-Loop Inbox |
user_status |
trillian-control |
Control | Status + Inbox-Depth of User-Loop |
user_stop / user_continue |
trillian-control |
Control | Pause/resume User-Loop |
user_clear / user_reset |
trillian-control |
Control | Clear Inbox / Soft-Reset |
user_attr_set(name, value) |
trillian-control |
Control | Set free-form attribute on User-Loop |
user_attr_clear / user_attr_list |
trillian-control |
Control | Clear all attributes / list them |
task_complete(taskId, result) |
— | User-Loop | Task success to Control |
task_failed(taskId, reason) |
— | User-Loop | Task failure to Control |
task_needs_input(taskId, question) |
— | User-Loop | Escalation to Control |
cross_process_create(projectId, recipe, name, goal, …) |
trillian-user |
User-Loop | Spawn Worker in any project |
peer_read_chat_memory(processName) |
— | User-Loop | Observe sub-worker live |
trillian_done(summary, data?) |
— | Worker | Signal DONE + Summary in chatLog |
trillian_session_create(initialMessage) |
— | external Engines | Spawn Trillian Session via Tool (Default-Nature) |
trillian_session_send(sessionId, message) |
— | external Engines | Address existing Trillian Session |
7. Cross-Project Mechanics
Trillian-User can spawn Workers in foreign projects via
cross_process_create(projectId, …). Mechanics:
- Tool validates:
projectIdexists in Tenant + is not SYSTEM. - Tool dispatches via
ActionExecutorRegistrywithTriggerContext. sessioned(tenantId, projectId=TARGET, …)— overridesctx.projectId()in the Action path. SpawnActionExecutorcallsThinkProcessService.createwithprojectId=TARGET. Prerequisite: Engine must declareallowsCrossProjectSpawn=true— Trillian-User does.- Spawned Worker has
process.projectId=TARGET,process.sessionId=Trillian-User-Session.id.
Unlike Eddie: Eddie’s DELEGATE_PROJECT/STEER_PROJECT
run via structured-action-schema and create a NEW Session
in the target project. Trillian’s cross_process_create is a direct
Tool without a structured-action funnel, and the Worker lands in
Trillian’s own Session with projectId override — no new
Session lifecycle per task. Result: fewer Sessions in the Tenant, all
Workers of a Trillian kept together under one Session,
parallelizable.
8. Bootstrap
TrillianSessionBootstrapper is called by SessionChatBootstrapper
after the Chat-Process has been created. Trigger:
process.thinkEngine == "trillian-control" (Nature-agnostic — the
Recipe alias trillian and all future Nature Recipes trigger
the same bootstrap path).
Sequence:
- Create
_trillian-0XXXXservice account (5-digit random numbers, uniqueness check in Tenant; Production Natures later with first name pool_trillian-aliciaetc.). - Read Nature from
controlProcess.engineParams.nature(Default0). - User-Recipe-Name =
trillian-user-+ nature; resolve viaRecipeResolver.applyDefaulting. - Create second Session — Owner =
_trillian-*, Profile =headless,system=true, in the same project as Control. - Spawn primary Process
trillian-user-loopin the second Session,parentProcessId = controlProcess.id(cross-session parent). - Set cross-references in
engineParamsof both Processes (peerProcessId,peerSessionId,trillianUserName). - Start User-Process on its own Lane (
thinkEngineService.startblocking via LaneScheduler).
9. Cleanup-Lifecycle
TrillianCleanupListener listens for ThinkProcessStatusChangedEvent
with newStatus == CLOSED. If the closing Process has the
trillian-control-Engine:
- Read Peer-Session-Id from
engineParams.peerSessionId SessionLifecycleService.closeWithCascade(peerSessionId)— closes the User-Loop-Process + all Worker-Processes- Delete
_trillian-*service account (UserService.delete)
Ephemeral in Nature-0 — no persistence, no user recycling.
10. ProcessEvent Persistence
Both Engines (TrillianControlEngine, TrillianUserEngine)
persist all non-UserChatInput SteerMessages
(ProcessEvent, Reply, ToolResult, ExternalCommand) as USER role in
ChatMessageDocument. Background:
Without persistence, a ProcessEvent only lives in the current Lane-Turn
(as an extras list). After natural-stop, it disappears. For
multi-turn correlation — task_request (Turn N) ↔ worker-reply
(Turn N+M) — the LLM can no longer find the taskId in N+M.
Persistence makes the XML-rendered event markup a permanent
part of the Chat-History, which the LLM sees as context in every turn.
SteerMessage.Reply (Worker-natural-stop) and
SteerMessage.ProcessEvent (terminal DONE/FAILED) are
rendered differently (<worker-reply …> vs.
<process-event type="done" …>) — both are explained in the
Trillian-User-Prompt as valid Task-Result signals.
10a. Trillian-User-Attributes (free-form)
Control can set arbitrary Key-Value pairs on the Trillian-User-Loop-Process
via user_attr_set(name, value). Storage:
process.engineParams.attributes (Map<String, Object>). In
Nature-0 ephemeral with the Session; from Nature-A+ this moves to a
Home-Project-Document (analogous to Eddie’s Personality-Pattern).
The active TrillianNature decides how the attributes are interpreted.
Nature-0 does both:
userPromptAddendum(process)— rendersprocess.engineParams.attributes(= the User-Loop’s own attributes) as a Markdown block in the User-Loop-Prompt.controlPromptAddendum(process)— followsprocess.engineParams.peerProcessIdto the User-Loop and renders the same attributes also in the Control-Prompt.
This makes Control + User-Loop consistent: if the human sets
user_attr_set(persona="witty Swabian who only speaks Swabian"),
both Control’s chat response and all spawned Workers speak in the
style of this Persona. Storage remains single-source-of-truth on the
User-Loop; Control reads cross-process via Peer-Lookup.
Render example (same markup on both sides, only one word context different):
## Attributes (currently active on this Trillian)
- **persona:** witty Swabian who only speaks Swabian
- **language:** German
- **tone:** factual
Nature-A+ can read the same map as a typed Persona schema (Traits vector, Mode default, Token budget hint), as a Memory-Cascade source for reflection phases, or as a routing hint for sub-worker Recipe selection. The convention of attribute names is up to the Control- LLM — Nature documentation recommends well-known names per Nature. And yes: Nature-A can decide differently whether Control should also see the attributes (e.g., if Control only delegates instead of responding itself) — the hook is overridable.
Tools: user_attr_set / user_attr_clear / user_attr_list,
all engine-role-gated to trillian-control. API:
TrillianInternalApi.setPeerAttribute /
clearPeerAttributes / static readAttributes(process).
10b. Resilience: Pod-Restart, Cross-Pod-Move, Suspend
Trillian is persistent in Mongo (Session/Process-Documents, ChatMessage-History, Engine-Inbox-Messages, engineParams.attributes). In-Memory are only the Lane-Queue + the Foot-WS-Binding of the Control-Session — both are reconstructible on boot.
Pod-Pinning: Sessions follow the home cluster of their project
(ProjectDocument.homeCluster). ProjectManagerService.
claimForLocalPod is atomic via Mongo-findAndModify — at
runtime there is exactly one Pod that runs the Lanes of the
Trillian-User-Loop. In case of Pod-Failure: ProjectStartupReclaimer
releases stale Claims, the next Pod takes over, Lanes wake up via
EngineMessageService.findInboxedByTargets on boot.
Suspend Behavior: Trillian runs daemon-style — Control-Recipes
(trillian.yaml, trillian-0.yaml) explicitly set
onIdle: NONE, and TrillianSessionBootstrapper explicitly pins the same
Daemon-Policy to the User-Session via
SessionService.applyLifecycleConfig. Thus:
- Async
task_doneevents from the User-Loop are processed even when the human is not actively chatting - Control does not wait for Resume-Cascade on reconnect
- Sweeper (
SessionIdleSweeper) automatically skips NONE-Sessions
onSuspend: KEEP with Default suspendKeepDurationMs: 24h. For
explicit suspend action (e.g., via process_pause), there is a 24h
period before the suspend sweeper closes — enough buffer for longer
breaks.
Cross-Project-Worker on Pod-Move: Worker-Process has
process.projectId = Target-Project, the Pod claiming this
Target-Project runs the Lane. If Trillian-User-Session is in
Project A (Pod 1) and spawns a Worker for Project B (Pod 2),
this is normal Vancetope Cross-Pod operation — EngineMessageRouter
routes ProcessEvents between the Pods.
11. Worker-Termination and enrichWithLastReply
Frankie in worker-mode skips its normal persistAssistantReply path
on tool-terminate — therefore, Workers terminating via
trillian_done must write the Summary themselves to
chatLog. TrillianDoneTool does this before returning
the _terminate=true result:
- Look up current Process from
ctx.processId() - Append Summary as ASSISTANT-Message to
ChatMessageDocument - Return Result-Map with
_terminate=true+ summary
This allows ParentNotificationListener.enrichWithLastReply to find
the Summary and append it to the DONE-ProcessEvent. Trillian-User
receives the actual Summary instead of the generic “Child process X
status=done”.
12. Vancetope-Core Adaptation — ThinkEngineService.newContext
Before Trillian, ThinkEngineService.newContext() always read the projectId
for the ToolInvocationContext from the Session
(session.getProjectId()). For Cross-Project-Workers
(process.projectId != session.projectId), this led to Tools
like doc_* / file_* always operating on the Session-Project,
not on the Worker-Project.
Fix (Trillian prerequisite, generally valid):
String processProjectId = process.getProjectId();
String projectId = (processProjectId != null && !processProjectId.isBlank())
? processProjectId
: session.getProjectId();
Process-projectId takes precedence. Risk-free for non-cross-project
Engines (where both are the same). Eddie’s DELEGATE_PROJECT is
unaffected, because a new Session is created in the target project anyway.
13. Limitations Nature-0
Deliberately excluded (coming from Nature-A):
- Personality (Traits, Principles)
- Reflection Phases (light / full / error analysis / periodic)
- Mode-Switch Low/High/Sleep with Cadence Tiering
- Token-Budget Soft/Hard with Setting-Cascade
- Plan-Revision / Correction-Check between Sub-Goals
- Trillian-User-Persistence beyond Session-Destroy
- Own
_user_<trillian-name>Home-Project (persistent Trillians) - True ACL for
_trillian-*(granular cross-project rights — currently runs onAllowAllPermissionResolver) - Self-Evolution of Traits between Runs
- Cortex-Right-Panel Display for Trillian-User-Status
These points will be reflected in Nature-A+ as overrides of the
TrillianNature hooks — no new Engine build required.
14. Tenant-Setup & Discovery
Bundled-Default-Recipes (in the cascade under
vance-brain/src/main/resources/vance-defaults/_vance/recipes/):
trillian.yaml— Default-Alias (today Nature-0)trillian-0.yaml— Pinned Nature-0 Controltrillian-user-0.yaml— Pinned Nature-0 User-Looptrillian-worker-0.yaml— Pinned Nature-0 Worker
Foot-Start:
java -jar vance-foot.jar chat --recipe trillian # default-Nature
java -jar vance-foot.jar chat --recipe trillian-0 # pinned Nature-0
Brain-Logs on Bootstrap show identity assignment + Session-Pair:
Minted Trillian service-account '_trillian-0XXXX' for control session '…'
Trillian user-session created id='…' owner='<trillian-id>' project='…'
TrillianUser.start tenant='…' session='<user-session>' id='<user-process>'
Bootstrapped Trillian pair: control id='…' session='…' / user id='…' session='…' trillianUser='_trillian-0XXXX'
15. References
planning/trillian-engine.md— Design process history (v1 Sibling-Processes → v2-Pivot to Two-Sessions, discussion of Eddie reuse, Cross-Project-Pattern decision, Chinese Whispers analysis). Remains as design note; this spec is authoritative.instructions/trillian-engine.md— Original idea + Nature concept (letter/digit prefix, evolution path).specification/think-engines.md— Engine-Registry,allowsCrossProjectSpawn, Lifecycle contract.specification/frankie-engine.md— Worker-Loop-Pattern,_terminateconvention, ParentNotificationListener.enrichWithLastReply.specification/eddie-engine.md— Cross-Project-Pattern via structured-action-schema; Trillian deliberately chooses a different path.specification/recipes.md— Recipe-Cascade,allowedToolsAddvs. spawn-time-membership.specification/architektur-scopes-clients.md— Session/Process- Scope hierarchy.CLAUDE.mdSection “Think-Process / Scope Specifics” — ProcessEvent, drainPending, Auto-Wakeup.