Vancetope — Fook Service
Built-in bug/feature triage system: a reporter (LLM or user) sends free text, Fook asynchronously decides whether to create a new ticket, merge it into an existing one, or discard it. The subsequent processing of tickets (status transitions, fixes, PR sync) is handled by Lunkwill — not in scope here.
Architecture:
LightLlmServicewith Recipefookas a configuration profile, plus a thin service with an in-memory queue and worker tick. No dedicatedThinkEngine, no Process spawn, no Lane lock.See also: light-llm-service | recipes | user-interaction | architecture-scopes-clients
1. Purpose & Scope
Problem. Vancetope bugs, feature requests, and documentation gaps require a low-threshold reporting channel — both for running Engines (“I cannot perform this operation, but it should be possible”) and for human users in Web and Foot. Without a built-in path, bugs disappear into Sessions and user minds.
Solution. Three reporter channels all feed into the same pipeline:
- LLM Tool
vance_support_request(text)— any Engine can independently report if it detects a Vancetope deficiency. Rate-limit max. 3 per Process-Lifetime to prevent loop spam. - Web Fook Button in the user menu of
EditorTopbar(globally accessible across all editors), opens a modal with a textarea. - Foot
/supportSlash Command with two modes: inline (/support Brain crashed on boot) or, without args, a Lanterna multi-line form.
All three call the same FookService.submit() server-side. The
service queues in-memory, a worker tick calls the
LightLlmService with Recipe fook, the
result is applied as a Document side-effect, and an Inbox item is
created for the reporter.
What Fook is not:
- Not a ticket editor — tickets are stored as YAML Documents in the
_vanceTenant; UI for this comes with Lunkwill. - No status lifecycle after
new— transitions totriaged/accepted/in_progress/resolved/closedare set by Lunkwill. - No GitHub/Jira sync, no fix suggestion, no PR generation — all Lunkwill.
- No cross-tenant visibility model — tickets are globally readable in
the
_vanceTenant (reporter identity as Document metadata), not isolated per-Tenant.
2. Architecture
┌─ Source Tenant A (User Project) ──────────────────────┐
│ │
│ Engine X (Arthur, Eddie, …) │
│ │ vance_support_request(text) │
│ ▼ │
│ VanceSupportRequestTool ─── FookService.submit() ──┐
│ │
│ User Menu Button in Web Topbar ─── POST ───────────┤
│ /support in Foot CLI ─── POST ───────────┤
│ (POST /brain/{tenant}/fook/submit) │
└──────────────────────────────────────────────────────┘
│
▼
┌─ FookService (per Brain Pod) ─┐
│ in-memory Queue │
│ @Scheduled tick (~2 s) │
│ │ │
│ │ 1. FookTicketService │
│ │ .searchSimilar() │
│ │ → Top-N Candidates │
│ │ │
│ │ 2. LightLlmService │
│ │ .callForJson( │
│ │ recipe="fook") │
│ │ → TriageResult │
│ │ │
│ │ 3. Side-Effect: │
│ │ - new_ticket: │
│ │ createTicket(...) │
│ │ - merge_into: │
│ │ updateRelations(.) │
│ │ - discard: nothing │
│ │ │
│ │ 4. MaximegalonService │
│ │ .create(...) │
│ ▼ (tenantId = reporter) │
└───────────────────────────────┘
Storage (read+write by FookTicketService):
_vance-Tenant / _tenant-Project / Documents:
_vance/fook/tickets/<uuid>.yaml ($meta.kind: fook-ticket)
Components (all in vance-brain/.../fook/):
FookService—submit()enqueued,@ScheduledTick drained, per-submission processing.FookTicketService— data ownership overfook-ticketDocuments (CRUD + Similarity-Search). Not exposed as LLM Tools; all writes run from FookService after LLM decision.VanceSupportRequestTool—@ComponentLLM Tool in the default Tool Inventory, with rate-limit.FookController— REST surface for UI clients.- Recipe
fook.yaml— Config profile forLightLlmService, located under_vance/recipes/and cascade-overridable.
2.1 Master Switch vance.fook.enabled
The entire Fook subsystem can be disabled per Brain instance via the
boot property vance.fook.enabled (default true). The switch
does not deactivate the Beans (no @ConditionalOnProperty), but
rather makes them do nothing internally — thus, behavior per surface
remains controlled:
- The LLM Tool
vance_support_requestremains visible in the Tool Inventory but returns{ status: "disabled", note }on every call — no enqueue, no budget consumption. The model thus explicitly learns that feedback is disabled, instead of unsuccessfully repeating it. - REST
POST /brain/{tenant}/fook/submitresponds with 503 Service Unavailable ("Feedback (Fook) is disabled on this brain"). Web modal and Foot/supportdisplay the error text — the web menu remains visible intentionally, the error only appears upon sending. - Central bottleneck:
FookService.submit()throws if Fook is disabled (defense-in-depth); however, the surfaces short-circuit before that. - Worker ticks (triage drain, session analysis drain, upstream send/poll) exit early — nothing is triaged, analyzed, or forwarded upstream.
On boot, FookService logs exactly one info line
(Fook feedback disabled (vance.fook.enabled=false) …) if the switch
is off, so it remains clear that feedback is intentionally disabled
and not silently broken.
The five affected Beans (FookService, FookController,
VanceSupportRequestTool, FookSessionAnalysisService,
FookUpstreamService) each read the property via
@Value("${vance.fook.enabled:true}") — one source, one key.
3. LLM Tool Surface
vance_support_request(text: string) → { submissionId, status, remainingBudget, note }
- Default Tool (
primary: true, auto-discovered inBuiltInToolSource). Every Engine sees it. - Labels:
write+side-effect. Plan mode strips it. - One parameter:
text— free text, anything the LLM wants to tell the reporter. Fook (server-side) derives Type (bug/feature/question/other), Severity, and Title from it. The reporter does not rate their own bugs. - Rate-Limit: max. 3 submissions per
processIdlifecycle,ConcurrentHashMap<String, AtomicInteger>in the Tool. When over-cap throws, the counter is decremented so failed calls do not burn slots. - Context-Enrichment: Tool resolves
ThinkProcessService.findById(processId)and populatesTicketContextwithprojectId/sessionId/processId/recipe/enginefrom the Process Document. - Tool-Description: explicitly states that this is NOT intended for user project data or ongoing user tasks — exclusively for Vancetope-as-a-system topics.
Reporter Kind: ENGINE.
4. REST Surface
POST /brain/{tenant}/fook/submit
Authorization: Bearer <jwt>
Content-Type: application/json
Request:
{
"text": "Brain crashes on boot when recipes.yaml is missing.",
"projectId": "web-redesign",
"sessionId": "sess-42"
}
projectId and sessionId are optional. UI surfaces that do not
have a Project context (e.g., the user menu on the index page) omit
them.
Response 200 OK (immediate, no waiting for triage):
{ "submissionId": "<uuid>", "status": "queued" }
Auth & Permission: Action.WRITE on Resource.Tenant(tenant).
JWT filter validates upstream that the path tenant matches the
tid claim. UserId comes from the subject claim and populates the
reporter as USER_DIRECT.
Consumers:
- Web —
FookSupportModal.vuein the user menu (componentEditorTopbar). Reads?project=/?sessionId=from the URL, callsbrainFetch('POST', 'fook/submit', { body }). - Foot —
SupportCommandwith inline and Lanterna form path. UsesBrainRestClientService.post().
Reporter Kind: USER_DIRECT.
5. Triage Flow
FookService.drainQueue() runs as a Spring @Scheduled with
fixedDelayString = "${vance.fook.tick:PT2S}". Per submission:
-
Candidate Lookup.
FookTicketService.searchSimilar(text, limit=8)— Mongo full-text page over allfook-ticketDocuments in the_vanceTenant,_tenantProject, path prefix_vance/fook/tickets/. In-memory Jaccard ranking on tokens (3+ chars, lowercase), top-N by score returned. v1-Cap: 500 scanned tickets per lookup; after this, it must switch to embedding recall. -
LightLlm Call with Tenant Fallback. Triage runs preferably in the system Tenant
_vance— uniform model, uniform decision quality independent of the reporter. Prerequisite: the_vanceTenant hasai.default.provider/ai.default.modelsettings configured (set by admin, once).If
_vanceis not configured (Day-1 default, or intentional Tenant-Pays architecture), the first call throws anAiModelResolver.UnknownModelException— Fook catches this and retries against the reporter Tenant. This keeps Fook operational even without admin setup, with the trade-off that triage quality may then vary depending on the reporter Tenant.try { LightLlmService.callForJson( recipe = "fook", pebbleVars = { text, candidates }, schema = { type: "object" }, tenantId = "_vance" // primary ) } catch (UnknownModelException) { LightLlmService.callForJson( recipe = "fook", pebbleVars = { text, candidates }, schema = { type: "object" }, tenantId = reporter.tenantId, // fallback projectId = context.projectId ) }Recipe is
internal: true,engine: jeltz. Thefook.yamlis in the bundled resources and found by every Tenant via cascade —tenantIdonly controls credential/settings lookup, not recipe lookup.If the fallback also fails (reporter Tenant is
_vanceitself or empty): the exception bubbles to the Failure Inbox.Other failures (schema validation after
maxAttempts, provider 5xx, …) do not trigger a fallback — onlyUnknownModelExceptiontriggers the retry. Otherwise, a temporarily down LLM would be paid for twice. - Decision Switch.
new_ticket→FookTicketService.createTicket(payload)with LLM-derivedTitle/derivedType/derivedSeverity, new UUID, reporter identity, origin context. IfneedSessionReport=trueand the origin context carries a Session + Process, an analysis job is additionally queued inFookSessionAnalysisService(§11).merge_into→FookTicketService.updateRelations(targetId, patch)—relationfrom the LLM controls whether extra links are merged underrelatedToorrootCauseOf.discard→ no Document operation.
- Inbox Item.
MaximegalonService.create(MaximegalonDocument)withtenantId = reporter.tenantId(cross-tenant —MaximegalonServicedoes not validate against caller scope, thetenantIdon the Document is the Source of Truth).originatorUserId = "fook"as an audit marker. TypeOUTPUT_TEXT, CriticalityLOW,requiresAction=false, Tag["fook"], Payload withdecision/ticketId/submissionIdfor UI deep-link.
Crash Behavior: Queue lives only in the JVM heap. Pod restart loses pending submissions without Inbox feedback — consciously accepted, the alternative would be a persistent queue with replay logic.
Race Conditions: Two Pods triage without cross-Pod sync. Simultaneous reports of the same problem can create two separate tickets. Consciously accepted — Lunkwill cleans up duplicates later.
6. TriageResult Schema
The LightLlm call returns a Map<String,Object>, parsed into
TriageResult. Three variants, tagged by decision:
6.1 new_ticket
{
"decision": "new_ticket",
"derivedType": "bug" | "feature" | "question" | "other",
"derivedSeverity": "low" | "medium" | "high",
"derivedTitle": "Brain crash on boot",
"englishTranslation": "<English body translation, or \"\" if already English>",
"needSessionReport": true | false,
"relatedTickets": ["<uuid>", ...],
"triageNote": "1–3 sentences (optional, English)",
"reason": "1 sentence for Inbox item"
}
needSessionReport is the triage’s hint that a distilled analysis
of the reporter’s Session would help the fixer (details in §11). If
the field is missing, false applies. Only a hint — the analysis
path has a second gate.
Severity heuristic: high = crash/data loss/security/user-blocking,
medium = degraded behavior, low = cosmetic. For non-bug types,
the Recipe defaults to medium.
Language Handling. Vancetope tickets go to an English-speaking
upstream tracker (see fook-upstream.md).
So maintainers can read without translation effort:
derivedTitleis always English — the LLM translates inline if necessary (titles are short, no separate field needed)englishTranslationis a complete English translation of the report body. If the original is already English: empty string- The backend assembles the final ticket description as
<englishTranslation>\n\n--- Original:\n\n<originalText>if translation is set, otherwise original verbatim. Code snippets, file paths, and log lines are not translated — only natural language prose. triageNoteis always English (maintainer-oriented)reasonmay remain in reporter’s language (reporter-oriented)
6.2 merge_into
{
"decision": "merge_into",
"targetTicketId": "<uuid>",
"relation": "duplicateOf" | "rootCauseOf" | "relatedTo",
"relatedTickets": ["<uuid>", ...],
"triageNote": "1–3 sentences",
"reason": "1 sentence for Inbox item"
}
targetTicketId must appear verbatim in the candidate list — the
Recipe prompt explicitly requires this, the Recipe loader checks the
JSON response against an object schema (Jeltz-style with
retry-on-violation).
6.3 discard
{
"decision": "discard",
"category": "project_data" | "documentation_question"
| "unrelated" | "nonsense" | "self_loop" | "other",
"reason": "1–2 sentences for Inbox item"
}
Discard Categories:
project_data— Reporter talks about user project content (“Document X is missing”), not Vancetope.documentation_question— genuine question about Vancetope, but answerable from existing Manuals; reporter is referred tomanual_read/how_do_i.unrelated— Off-topic, has nothing to do with Vancetope.nonsense— Gibberish, no signal, “asdf”, empty noise.self_loop— Fook submitted via Fook (recursion).other— Fallback if nothing fits.
On Failure (LightLlm exception, missing/unknown decision field),
FookService writes a Failure Inbox item with payload
{ decision: "failed", error: <ExceptionClassName>, submissionId }
and performs no Document side-effect action.
7. Ticket Document Schema
Format YAML (not Markdown — tickets are structured, prose
content is small). Storage convention uses the $meta: wrapper
pattern from vance-shared/document/YamlHeaderStrategy — scalar
fields in $meta, all nested/prose as top-level keys next to it.
Path: _vance/fook/tickets/<uuid>.yaml in the _vance Tenant,
_tenant Project.
$meta:
kind: fook-ticket
id: 7e3f1c2a-...
title: "Brain crash on boot"
type: bug # bug | feature | question | other
severity: high # low | medium | high
status: new # Lunkwill manages later lifecycle
duplicateOf: null # only relation as scalar in $meta
reporterKind: engine # engine | user_direct | service_account
reporterUserId: alice
reporterTenantId: acme
reporterServiceAccount: null
createdAt: 2026-06-09T12:34:56Z
triagedAt: 2026-06-09T12:34:58Z
triagedBy: fook # always "fook" in v1
analysisRef: _vance/fook/tickets/7e3f1c2a-....analysis.md # if Report written (§11)
analysisStatus: written # written | skipped | failed (missing = never requested)
description: |
Original submission text from the reporter, verbatim.
triageNote: |
Optional. What led Fook to the decision.
context:
projectId: web-redesign
sessionId: sess-...
processId: proc-...
recipe: arthur
engine: arthur
relations:
rootCauseOf:
- <uuid>
relatedTo:
- <uuid>
Field Distribution — Rule:
$meta— all scalars suitable forsearchSimilaror for Lunkwill as a filter. The one scalar relationduplicateOfremains here for quick lookups.- Body keys — prose (
description,triageNote) and nested structures (context,relations).
Status Value Range v1: only new. Fook sets to new once and
then is out.
kind-Indexing: DocumentService.applyHeader() extracts
$meta.kind and writes it to the indexed
DocumentDocument.kind column. listByProjectPaged(..., kind =
"fook-ticket") filters directly on this.
8. Reporter Identity
| Kind | Source | Inbox Target |
|---|---|---|
ENGINE |
vance_support_request from running Process |
process.userId in process.tenantId |
USER_DIRECT |
Web Fook Button / Foot /support |
Active User in Path Tenant |
SERVICE_ACCOUNT |
Tool call from Daemon/Scheduler without User | v1: no Inbox item, only log |
Service account submissions are correctly triaged and create tickets — only the Inbox feedback is omitted because there is no human recipient.
8.1 Empty-Response Diagnostics — ai-diagnostics with Dedup-Gate
The first service account reporter is not a Tool, but server
diagnostics: If the Resilient Layer, after exhausting empty retries,
fires the ChatRequest to diagnostics and it finds a Built-in Tool
name in the conversation text that was not in the offered
tools array (phantomToolCallSuspected, see megadodo-
system §3.2), exactly one Fook ticket is
created per signature and re-arm window. The gate is a pair of
Tenant settings:
| Key | Semantics |
|---|---|
ai.diagnostic.phantomToolCall.reArmDays |
Window in days; Default 14. 0 reports every occurrence. Invalid values fall back to default, never to reporter failure |
ai.diagnostic.phantomToolCall.reported.<sig> |
Marker line with report timestamp; <sig> = SHA-256 abbreviation of model label + sorted candidates — this keeps the key limited, no matter how the candidate list grows |
The marker is written before submit(): a burst of identical
occurrences (phantom calls are deterministic — same prompt, same
Recipe, same model) thus files at most one ticket per window; if the
submit fails, the next occurrence after the window reports again. An
unparseable marker re-arms immediately — one ticket too many is
better than one lost. The generic blank case (emptyModelResponse
without candidates) never files a ticket: no evidence, no leverage
— it only counts in the Megadodo feed.
9. Inbox Item
Exactly one MaximegalonDocument per submission (except
service_account path).
| Field | Value |
|---|---|
tenantId |
reporter.tenantId (NOT _vance) |
assignedToUserId |
reporter.userId |
originatorUserId |
"fook" (Audit marker) |
originProcessId |
context.processId if available |
originSessionId |
context.sessionId if available |
type |
OUTPUT_TEXT |
criticality |
LOW |
requiresAction |
false |
tags |
["fook"] |
title |
“Ticket created” / “Merged into existing ticket” / “Submission not opened as a ticket” / “Submission could not be triaged” |
body |
1–2 sentences with outcome + reason + ticket ID if available |
payload |
{ decision, ticketId?, category?, submissionId, error? } for UI deep-link |
The Inbox component in user-interaction.md knows the tag
"fook" as a filter criterion.
10. Recipe fook
Located under
vance-brain/src/main/resources/vance-defaults/_vance/recipes/fook.yaml.
Cascade-overridable per Tenant/Project — Tenants can override the
Recipe in their _vance/recipes/fook.yaml (e.g., to add their own
discard categories or sharpen the severity mapping).
engine: jeltz
internal: true
params:
model: default:analyze
maxAttempts: 3
temperature: 0.0
promptPrefix: |
<Pebble-Template with {{ text }} and {{ candidates }}>
tags: [internal, fook, triage]
internal: true is mandatory — LightLlmService rejects
non-internal Recipes. engine: jeltz is also mandatory: the
LightLlm call runs through Jeltz’s single-shot schema loop.
The promptPrefix template is compile-validated during Recipe load
(Pebble syntax fail = fail-fast boot error).
11. Session Analysis Report
Optional second step: for a newly created ticket, Fook generates a distilled analysis report from the reporter’s Session and attaches it as a sidecar document.
Why. The ticket fixer Lunkwill potentially runs on a different system and has no access to the Session. The report is thus the only bridge over which Session context (what the Engine attempted, where it broke) crosses the system boundary to the fixer. Fook holds the Session reference at triage time — this step uses it while it is fresh.
Trigger + Double Gate.
- The Triage LLM sets
needSessionReportin thenew_ticketbranch (§6.1) — a hint that an analysis would help. FookServicequeues the analysis job only if a Session and Process context is also present. This is the Engine report path (vance_support_requestcarries both); user-direct reports without Process are v2. Requested-but-not-analyzable →$meta.analysisStatus = skipped.- The analysis model may return
useful=falseafter viewing the Session (Session contained nothing valuable) → no sidecar. Two gates: Triage heuristic + actual Session view.
Execution — FookSessionAnalysisService, agentic loop. Own
in-memory queue + own @Scheduled tick (vance.fook.analysis.tick,
default 5s), separate from the Triage tick, so Session loading does
not slow down Triage throughput. Timing is non-critical.
A Session can be much larger than a context window. Therefore, the report is not generated from a truncated transcript in one shot, but in a bounded ReAct loop where the model works with tools over the data — like a human analyst:
- Load active Chat History once via
ChatMessageService.activeHistory(tenantId, sessionId, processId)(data ownership — never directly on the Chat collection). Contains, where available, the compaction summary as a regular message; empty →skipped. Messages are stored server-side as an indexed list (index[i]); this is not a context window problem — only the prompt must never contain the entire Session. - Per turn, a
LightLlmService.callForJson(Recipefook-session-analysis) that returns exactly one action:overview— Count/Roles/Time span/Index range + first/last snippets (provided as a seed before Turn 1).search{query}— Keyword search → hit indices + snippets.grep{regex}— Java regex search → hit indices + snippets.read{from,to}— Full text of an index range (truncated, pageable).finish{useful, report}— End. Fook executes the action against the in-memory list (plain grep/slice) and appends the observation to a scratchpad, which is passed to the next turn as anobservationsvar. The model extracts excerpts — it never gets the Session completely.
- Runs in the reporter Tenant/Project, not
_vance— that’s where the Session is, and potentially sensitive content remains with the user-configured provider. (Asymmetric to Triage, which prefers_vance.) - Bounds: Step budget (
vance.fook.analysis.max-steps, default 24, visible to the model per turn asstepsLeft) — safety net against runaway, not a goal; a targeted analysis typically needs 4–10 investigative calls +finish, the model finishes early. Additionally, match/snippet/read caps + scratchpad cap (oldest observations fall out, seed overview remains).finishwithuseful && reportnon-blank →FookTicketService.writeAnalysis;finishnot-useful/blank →skipped;MAX_STEPSwithoutfinish→skipped(outcomeexhausted).
Recipe fook-session-analysis. Bundled under
_vance/recipes/fook-session-analysis.yaml, engine: jeltz,
internal: true, model default:analyze. It is the per-turn
prompt of the loop: Tool description + action schema + goal; Pebble
vars ticketTitle/ticketType/reason/triageNote/engine/recipe
plus stepsLeft + observations. The prompt explicitly targets
Vancetope system behavior, not user content dump.
Storage — Sidecar. Report as a sibling document
_vance/fook/tickets/<uuid>.analysis.md with Markdown front-matter
kind: fook-ticket-analysis (separate kind → does not appear in any
fook-ticket scan). The ticket $meta gets analysisRef +
analysisStatus=written. Not inline, so the ticket YAML remains lean
(searchSimilar scans it) and upstream transport can handle the
attachment separately.
Privacy — Critical Path. The report distills potentially the same
sensitive data for which the Session is not attached. When written,
it is secret-scrubbed like the description (FookTicketAnonymizer
.scrubSecretsAtRest) and must undergo the same fook-upstream scrub
(reporter hash + regex) during external transport — see
fook-upstream.md.
Failure + Crash. Analysis failure is non-fatal — ticket + Inbox
item already exist; ticket is stamped analysisStatus=failed, no
Failure Inbox item. Queue is JVM-heap-only like the Triage queue; Pod
restart loses pending analyses (ticket survives, only the report is
missing).
v2 (consciously postponed): Reports for merge_into (multiple
analyses per ticket), persistent analysis queue, user-direct
session-wide analysis without Process.
Design-Trail: planning/fook-session-report.md.
12. Lifecycle after new — Out of Scope (Lunkwill)
The following is explicitly not in this spec — Vancetope only prepares tickets locally:
- Status transitions after transfer (
triaged→accepted→in_progress→resolved→closed) happen in the external ticket system (GitHub Issues), not in Vancetope. Vancetope only mirrors the open/closed state back. - Aggregate reports on tickets.
- Knowledge graph entries for ticket relations.
- Cross-Pod queue synchronization.
- Web UI for ticket browsing in Vancetope — the canonical UI is the external ticket system.
Outbound transport (local triage → external system) is covered by
fook-upstream.md. Lifecycle after transfer
is Lunkwill’s responsibility and happens in the external system.
13. Quotas, Metrics, Observability
Quotas: Hard rate-limit on the Tool side (3 per Process). REST/UI are not hard-limited in v1 — user-direct submissions are trustworthy.
Micrometer Counters (see CLAUDE.md metrics convention):
vance.fook.submissionswith tagsource∈{engine, user_direct, service_account}vance.fook.triagewith tagoutcome∈{new_ticket, merge_into, discard, failed}vance.fook.analysiswith tagoutcome∈{written, skipped_not_useful, skipped_no_session, exhausted, failed}(§11)vance.fook.tickets.scanned(distribution — how large was the candidate search?)
No high-cardinality tags (no tenantId/projectId).
Audit Trail: Every ticket creation logs with
reporter.kind/userId at INFO. Every cross-tenant Inbox write logs
with targetTenant. Ticket Documents carry
reporterKind/reporterUserId/reporterTenantId in $meta.
14. References
- light-llm-service — Single-shot LLM call helper, consumed by Fook.
- recipes — Recipe system,
internal: truemarker. - user-interaction — Inbox subsystem.
- architecture-scopes-clients —
_vanceTenant +_tenantProject convention. - web-ui —
EditorShell/EditorTopbar, user menu. - java-cli-modulstruktur — Foot Slash Command pattern.