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 │
│ ┌──────┴────────────────┐ │
│ │ TrillianNatureVoid │ Nature `void`: all defaults │
│ │ TrillianNatureA … │ Nature-A: personality, … │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Class Structure. TrillianNature (SPI) → TrillianNatureBase
(abstract, all shared mechanics: attribute map and its rendering into
both prompts, peer lookup) → concrete Natures. TrillianNatureVoid is
an empty derivation of the base — this is what makes it the baseline.
New Natures derive from the base, not from Nature void: otherwise,
“what every Trillian does” and “what Generation 0 does” would be the same class, and
a later void Nature change (or an experiment there) would silently
land in every descendant.
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-void— 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-adam(Nature-A) — the first Nature with its own behavior. Character on creation, durable attributes, reflection after task completion. Own spec:specification/public/trillian-nature-adam.md.- Further Natures … — Personality, Traits, Mode Switch, Token Budget.
Nature IDs are words, not characters. [a-z0-9]+ is allowed —
0, a, but also alpha or fast. The ID is used in the
account name and in three recipe names, so TrillianNatureRegistry
checks it on boot and aborts startup if
it contains a hyphen (then the account name could no longer be
decomposed) or is user/worker (then the
Control Recipe trillian-user would collide with the User Loop family). An
unknown ID is something else — it falls back to Nature void at runtime with a WARN,
because that is a configuration mismatch and must not kill a
running Engine.
The former convention “digits = test, letters = production” has been dropped. It had no consumer, and with descriptive IDs, the distinction is in the name anyway.
Recipe Convention:
| Recipe | Meaning |
|---|---|
trillian-void |
Nature void — Baseline, no persistence |
trillian-adam |
Nature-A adam — persistent attributes |
trillian-user-<n> |
User Loop Recipe per Nature — resolved from bootstrap using USER_RECIPE_PREFIX + nature |
trillian-worker-<n> |
Per-Task Worker per Nature — similarly derived (WORKER_RECIPE_PREFIX + nature) and passed to the User Loop as params.workerRecipe |
There is no trillian anymore. As long as void was the only Nature,
an alias to “the current one” was convenient and harmless. With adam, it
became incorrect: Natures differ in what they are —
adam’s attributes survive a restart, void’s do not — a recipe “take
the newest” would therefore silently start a different agent
on the next default change. The pinned names are meant to protect against
exactly that; an alias next to it removes the protection for whoever uses it.
Both Natures are instead listed: true and appear side-by-side in the
Recipe Picker. Where a default is still needed because no one was asked
— trillian_session_create without a recipe argument — trillian-void is now
used as the named Nature (DEFAULT_CONTROL_RECIPE), not
as a movable target.
The bootstrap derives both subsequent Recipes from the Nature, no one
types them. The worker name was previously a literal in the User Loop prompt
(“use trillian-worker-void”) — that would have forced every new Nature to fork the
prompt just for one word, and a model that mistypes would spawn nothing.
Now the prompt reads {{ params.workerRecipe }}; derivation happens in exactly one place in
Java.
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-<nature>-<instance> (Service Account)
Engine: trillian-user
Primary process: 'trillian-user-loop'
Tools: project_list, process_spawn, 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-void)
▼
Worker-Process — per Task, in the Trillian-User-Session but with
process.projectId = <Target Project>
Owner: _trillian-<nature>-<instance>
Engine: frankie (via trillian-worker-void recipe)
Tools: full Worker toolset (doc_*, file_*, exec_*) +
trillian_done for Termination
Why two Sessions:
| Aspect | Sibling Processes (discarded) | Two Sessions (Nature void) |
|---|---|---|
| Runtime Identity | 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 → possibly 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_spawnindirection, 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 returns 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 other 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 void Nature User Loop would run autonomously for many turns under the _trillian-<nature>-<instance> service account and exceed the context window / cause 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 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 (without recipe argument: trillian-void) |
trillian_session_send(sessionId, message) |
— | external Engines | Address existing Trillian Session |
Additionally, the generic wakeup_in(seconds, label, payload) /
wakeup_cancel(correlationId) in the User Loop: the loop can
schedule itself to be woken up later, instead of ending a turn without
a follow-up. The Wakeup Registry is in-memory — a Brain restart
discards scheduled wakeups without a trace, and a PAUSED/SUSPENDED/CLOSED
process swallows them (otherwise, expired ticks would pile up in an
inbox that no one drains during hours of pause). Thus, it is suitable for
polling over minutes to hours, not for a standing agenda over days.
Where Vance knows the time itself, polling is the wrong form anyway:
PermissionRequestEffect notifies the requesting process directly as soon as
its access request has been decided — no interval to guess, no LLM turn
that determines “not yet”.
6a. Direct Control: //trillian
The user_* tools do the same, but only when Control is currently
responding and selects the appropriate tool. The command channel is the way for
the human: deterministic, token-free, and available when a
turn is stuck — precisely when you want to check or stop something.
| Command | Effect |
|---|---|
//trillian / //trillian info |
State overview: Control (Status/Session/Nature), Worker (Account Name, Status, Inbox Depth, Attributes), list of running Task Workers with target project and age |
//trillian queue |
Contents of the Worker Inbox: for each entry, type (task_request / task_done / …), taskId, truncated description, age |
//trillian task <description> |
Enqueue task directly, without Control LLM |
//trillian stop |
Set Worker Loop to PAUSED |
//trillian continue (alias resume) |
Back to IDLE + wake Lane |
//trillian clear |
Discard pending Task Requests, result events remain |
//trillian clear all |
Discard the entire Inbox, including results |
//trillian attr |
List Worker attributes |
//trillian attr set <name> <value> |
Set attribute — the rest of the line is the value, no quoting needed |
//trillian attr del <name> / attr clear |
Remove single attribute / all attributes |
The queue is not a task list. In the Worker’s Engine Inbox,
besides waiting task_requests, there are also result events
(task_done, Worker replies) that the loop retrieves in the next turn and
reports to Control. A blanket clear would therefore discard completed work:
the loop would never learn the result, and the task would remain open forever.
Therefore, clear separates the two, and queue shows the type per entry —
you see what you’re affecting before deleting.
task makes the User Loop individually testable. Otherwise, it depends on
Control: if the chat LLM doesn’t formulate the task or its provider call
gets stuck, the loop doesn’t even get a chance. The command enqueues via
the same TrillianInternalApi.enqueueTask as task_enqueue — a manually
submitted task is indistinguishable from one submitted by Control; the
only difference is who formulates the text (Control with query discipline,
or the human verbatim).
Lane Semantics. The handler reports runsOnLane() = false. Each
subcommand reads or targets the Peer, never the addressed
Control process; mutations serialize on the Peer Lane (in
TrillianInternalApi.pausePeer / resumePeer). Waiting on the Control Lane
would be backwards: a stop that is queued after the turn it is supposed to
interrupt is not a stop. See
engine-commands §5.
pausePeer does not wait on the Peer Lane either. It immediately sets the
out-of-band requestHalt flag and only queues the PAUSED write operation
— without a join. This is the difference between “takes effect in the current turn”
and “takes effect after the current turn”: both Trillian Engines
read OrchestratorInterrupt.probe(...) in the loop head, thus seeing the flag
mid-turn and exiting at the next safe boundary. The queued Lane task then
writes PAUSED and clears the flag — the same form as
SessionLifecycleService.pauseActiveInSession, just without the join.
Waiting on the Lane would mean waiting for the very turn one is interrupting.
pausePeer is idempotent: an already PAUSED or CLOSED Peer is
returned unchanged. The counterpart resumePeer clears the halt flag
always, even if the status was not PAUSED — a pause that never
reached a turn leaves it set, and a resume without clearing would give the Peer
a turn that exits at its first loop-head check.
One implementation, two entry points. Command handler and
user_* tools call the same TrillianInternalApi. Everything else
drifts apart, and then you debug the control instead of the issue.
No ESC replacement. ESC / /pause act per session
(pauseActiveInSession → findBySession) and do not reach the
Peer Session — this is intentional: the Recipes set
onIdle: NONE and the Bootstrapper pins the same Daemon Policy because
the Worker should continue running when the human is not actively doing anything. A
control cascade across the session boundary would destroy this property
and incidentally control a foreign, differently-owned session. The
lifetime cascade on session close (§9) is unaffected — it is
lifecycle, not control.
7. Cross-Project Mechanics
Trillian-User can spawn Workers in other projects via
cross_process_create(projectId, …). Mechanics:
- Tool validates:
projectIdexists in Tenant + is not SYSTEM. - Tool dispatches via
ActionExecutorRegistrywithTriggerContext. sessioned(tenantId, projectId=TARGET, …)— overwritesctx.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 — all
Nature Recipes, current and future, trigger the same
bootstrap path).
Sequence:
- Adopt or create service account. If a process in this session
already has a
trillianUserNameand the account still exists, it is reused — this is the case after reactivation, where the archived, renamed chat process carries the name forward. Only otherwise is a fresh account minted, named_trillian-<nature>-<instance>(e.g.,_trillian-void-1535,_trillian-alpha-4711): leading_as service account marker, the Nature ID as a separate part, four random digits as instance with uniqueness check in the tenant. Three parts, so the Nature can be read from the name and is not limited to a single character. The display name is onUserDocument.titleand is only initialized on creation (Trillian <nature>-<instance>) — a human can freely rename it in the User Editor without the account name changing;//trillian infoshows both. When adopting, step 1b is skipped — the grant already exists. 1b. Seed authority —PermissionBootstrap.grantProjectAdmin(tenant, controlProject, trillianName)viaObjectProvider.ifAvailable(no-op if an external Governor manages permissions). Without grant, the account exists but can do nothing: every tool call runs viaToolDispatcher→enforce(EXECUTE), which resolves to WRITER-on-project. Scope is deliberately the Control Project — Trillian represents the human in the project where it was started, and nowhere else. Cross-project spawn (cross_process_createinto a foreign project) remains denied until someone explicitly grants it (see §13). - 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. Owner is the account name, not the Mongo ID:SessionDocument.userIdcarriesUserDocument.nameby contract, and the entire auth chain (SecurityContext, Team lookup, Grant matching) keys off that. - 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). 6b. Announce identity in Control Chat — a persistent ASSISTANT-ChatMessageDocumentnames the minted account name, its project, and a note that it needs to be enabled for other projects. Deliberately persistent instead ofNOTIFY: the name is random per session, only needed later (grant for a second project) and would be gone as a fleeting notification. Best-effort — an error here does not abort bootstrap. - 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 with it- Revoke account grants (
PermissionBootstrap.revokeAll), then delete the_trillian-*service account (UserService.delete). The order is binding:UserService. deletedoes not cascade into grant storage; a grant must not outlive its subject. Errors during revoke do not block account deletion.
Exception: Archiving. The archive cascade closes every process in the
session, so it would also land here and destroy the account —
“archive” would thus effectively mean “discard”, and reactivating would
mint a stranger. The listener therefore exits on
CloseReason.ARCHIVED; this case is handled by the lifecycle hook.
9a. Session Lifecycle Cascade
The process status is the wrong hook for something that depends on the
session: any path that ends a session without explicitly setting
that exact process to CLOSED would silently bypass cleanup — a deleted
Control would leave the Worker session, along with ChatMessages, Processes,
and Memories, as a shell (invisible because system=true).
TrillianSessionLifecycleHook therefore implements the generic
SessionLifecycleHook and pulls the
Worker session along:
| Control | Worker Session | Service Account |
|---|---|---|
| closed | closed | deleted (listener above) |
| archived | archived | remains |
| reactivated | old deleted, new built | same (adoption, §8.1) |
| deleted | hard deleted | deleted |
Attributes travel with it. They reside in the engineParams of the
Worker process (§10a) — Mongo-persistent, thus surviving restart,
pod change, and archiving itself. Only reactivation deletes the
old Worker session and thus its carrier. The hook therefore saves them
beforehand to the closed Control process
(engineParams.carriedWorkerAttributes), and the bootstrap sets them on
the new Worker during adoption — and clears the temporary storage again
so no outdated Persona is resurrected.
The account survives archiving — archived means put away,
not discarded. A Trillian that returns with a different identity, without
attributes, and without permissions has not returned.
Downside: an archived Trillian continues to hold its project grants,
even though no one uses them (visible via permission_grant_list); if
you want to reclaim them, delete the session instead of archiving it.
Recursion protection via the Engine, not via wiring. The
hook only acts on a session that has a process with the engine
trillian-control. The wiring is not suitable for this: both
sides carry peerSessionId, each pointing to the other (the
bootstrap sets it in §8 step 6 on Control and User Loop). Anyone
keying off that would send the deletion back and forth between the two
sessions until the stack is full — observed as StackOverflowError on the first
reactivate — and along the way, the Worker side would delete the shared
service account because trillianUserName is also on both.
Nature void remains ephemeral insofar as the account is bound to its session
— it does not exist beyond the session’s end. However, it persists across
Brain restarts, pod changes, and archiving (§10b).
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 that 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>) — at
runtime for every Nature, including adam. What distinguishes
the Natures is durability (§10a, “Durability”).
The active TrillianNature decides how the attributes are interpreted.
Nature void 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 in the Control prompt as well.
This ensures Control + User Loop behave consistently: 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 for attribute names lies with 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).
Nature-Specific: Durability, Character, Reflection
How a Nature handles attributes is its decision, not the framework’s.
Nature void keeps them in engineParams, where they die with the process lines.
Nature-A adam gives each Trillian a character, stores attributes as a document,
and maintains a reflection journal — all via the hooks below, without the Engine
knowing about it.
Fully described in specification/public/trillian-nature-adam.md.
The hooks the framework offers for this:
| Hook | Who calls | Purpose |
|---|---|---|
callName(attributes) |
Bootstrap (greeting) | Call name, Default "Trillian" |
initialAttributes(tenant, project, account) |
Bootstrap, if nothing passed | Starting values for a fresh Worker Loop |
attributesChanged(worker, attributes) |
TrillianInternalApi after each mutation |
Establish durability |
taskConcluded(worker, taskId, outcome, summary) |
dispatchTaskEvent, only done/failed |
Learn from a conclusion |
accountDiscarded(tenant, project, account) |
TrillianSessionLifecycleHook, before account deletion |
Release everything under the name |
controlPromptAddendum / userPromptAddendum |
Both Engines per turn | Prompt overlay |
beforeControlTurn / afterControlTurn / beforeUserTurn / afterUserTurn |
Both Engines | Turn lifecycle |
attributesChanged is deliberately tied to the mutation funnel in
TrillianInternalApi, not to the tools: user_attr_set and
//trillian attr set share this API, and a Nature that only learned from
one of the two would be worse than one that learned from neither.
The same applies to taskConcluded and dispatchTaskEvent. All hooks
swallow errors — the authoritative write operation has already occurred
at this point.
10b. Resilience: Pod Restart, Cross-Pod Move, Suspend
Trillian is persistent in Mongo (Session/Process Documents, ChatMessage History, Engine Inbox Messages, engineParams.attributes). Only the Lane Queue + the Foot WS Binding of the Control Session are in-memory — both are reconstructible on boot.
Pod Pinning: Sessions follow their project’s home cluster
(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 — the Control Recipes
(trillian-void.yaml, trillian-adam.yaml) explicitly set
onIdle: NONE, and TrillianSessionBootstrapper explicitly pins the same
Daemon Policy to the User Session via
SessionService.applyLifecycleConfig. This means:
- Async
task_doneevents from the User Loop are processed even when the human is not actively chatting - Control does not wait for a resume cascade on reconnect
- Sweeper (
SessionIdleSweeper) automatically skips NONE sessions
onSuspend: KEEP with default suspendKeepDurationMs: 24h. For
explicit suspend actions (e.g., via process_pause), there is a 24h
grace 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 Worker spawns 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
gets 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 meant that
tools like doc_* / file_* always operated 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, as a new session is created in the target project anyway.
13. Limitations of Nature void
Deliberately excluded (comes from Nature-A; what adam already
implements is marked):
- Personality (Traits, Principles)
- Reflection Phases (light / full / error analysis / periodic) —
a simple form built into Nature-A
adam(one line per completion, success or failure); the categorization by type is open - 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 — for
attributes solved in Nature-A
adam(see its spec), for the rest open - Own
_user_<trillian-name>Home Project (persistent Trillians) - Automatic Cross-Project Permissions for
_trillian-*. The bootstrap grants ADMIN on exactly one project (that of the Control Session, §8 step 1b);cross_process_createinto a foreign project results in a DENY without explicit permission. This remains so — an ephemeral, LLM-controlled account cannot grant itself permissions (otherwise self-escalation). The path to this has been explicit permission since 2026-08-10:user_project_request(projectId, reason)(control-role-gated) submits a request that an administrator of the target project approves via the Inbox — only then doesallowsCrossProjectSpawn=true(§7) become practically effective. The generated account name remains internal; the tool fills in subject and role (WRITER) itself. Mechanics:planning/permission-request-inbox.md. - Self-Evolution of Traits between runs
- Cortex Right Panel Display for Trillian-User Status
These points are reflected in Nature-A+ as an override of the
TrillianNature hooks — no new Engine build needed.
14. Tenant Setup & Discovery
Bundled Default Recipes (in the cascade under
vance-brain/src/main/resources/vance-defaults/_vance/recipes/):
trillian-void.yaml— NaturevoidControl (listed)trillian-user-void.yaml— NaturevoidUser Looptrillian-worker-void.yaml— NaturevoidWorkertrillian-adam.yaml— NatureadamControl (listed)trillian-user-adam.yaml/trillian-worker-adam.yaml/trillian-adam-reflect.yaml— the adam family
Foot Start:
java -jar vance-foot.jar --recipe trillian-void # Nature `void`
java -jar vance-foot.jar --recipe trillian-adam # Nature `adam`
Brain Logs on Bootstrap show identity assignment + session pair:
Minted Trillian service-account '_trillian-<nature>-<instance>' 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-<nature>-<instance>'
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, “telephone game” analysis). Remains as design note; this spec is authoritative.specification/public/trillian-nature-adam.md— Nature-Aadam: Character, durable attributes, reflection journal, worker episodes.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 Peculiarities” — ProcessEvent, drainPending, Auto-Wakeup.